mirror of
https://github.com/markqvist/Reticulum.git
synced 2026-06-23 12:24:30 -07:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -1,3 +1,23 @@
|
||||
### 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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+329
-18
@@ -40,7 +40,9 @@ from tempfile import TemporaryDirectory
|
||||
|
||||
from RNS._version import __version__
|
||||
from RNS.Utilities.rngit import APP_NAME
|
||||
from RNS.Utilities.rngit.pages import NomadNetworkNode
|
||||
from RNS.vendor.configobj import ConfigObj
|
||||
from RNS.vendor import umsgpack as mp
|
||||
|
||||
def program_setup(configdir, rnsconfigdir=None, verbosity=0, quietness=0, service=False, interactive=False, print_identity=False):
|
||||
targetverbosity = verbosity-quietness
|
||||
@@ -102,9 +104,13 @@ class ReticulumGitNode():
|
||||
PERM_READ = 0x01
|
||||
PERM_WRITE = 0x02
|
||||
PERM_READWRITE = 0x03
|
||||
PERM_CREATE = 0x04
|
||||
PERM_STATS = 0x05
|
||||
PERM_R_SMPHR = ["r", "read"]
|
||||
PERM_W_SMPHR = ["w", "write"]
|
||||
PERM_RW_SMPHR = ["f", "full", "rw", "readwrite"]
|
||||
PERM_RW_SMPHR = ["rw", "readwrite"]
|
||||
PERM_C_SMPHR = ["c", "create"]
|
||||
PERM_S_SMPHR = ["s", "stats"]
|
||||
|
||||
TGT_NONE = 0x01
|
||||
TGT_ALL = 0x02
|
||||
@@ -131,17 +137,25 @@ class ReticulumGitNode():
|
||||
self.global_allow = RNS.Destination.ALLOW_ALL
|
||||
self.groups = {}
|
||||
self.active_links = {}
|
||||
self.page_servers = {}
|
||||
self.stats = {}
|
||||
self.last_announce = 0
|
||||
self.announce_interval = 0
|
||||
self.stats_enabled = False
|
||||
self.stats_job_interval = 180
|
||||
self.last_stats_job = time.time()
|
||||
self.link_clean_interval = 5
|
||||
self.last_link_clean = 0
|
||||
self.active_links_lock = Lock()
|
||||
self.stats_lock = Lock()
|
||||
self.stats_ignored = {}
|
||||
self.node_name = "Anonymous Git Node"
|
||||
|
||||
self.config = None
|
||||
self.verbosity = verbosity or 0
|
||||
self.ready = False
|
||||
self._should_run = False
|
||||
self._serve_nomadnet = False
|
||||
|
||||
if not self.__ensure_git(): RNS.log("The \"git\" command is not available. Aborting server startup.", RNS.LOG_ERROR)
|
||||
else:
|
||||
@@ -157,6 +171,7 @@ class ReticulumGitNode():
|
||||
RNS.logfile = self.configdir+"/server_log"
|
||||
self.configpath = self.configdir+"/config"
|
||||
self.identitypath = self.configdir+"/repositories_identity"
|
||||
self.statspath = self.configdir+"/stats"
|
||||
|
||||
if os.path.isfile(self.configpath):
|
||||
try: self.config = ConfigObj(self.configpath)
|
||||
@@ -172,6 +187,7 @@ class ReticulumGitNode():
|
||||
exit(1)
|
||||
|
||||
self.__apply_config()
|
||||
self.__load_stats()
|
||||
|
||||
if print_identity:
|
||||
client_identity_path = self.configdir+"/client_identity"
|
||||
@@ -183,15 +199,20 @@ class ReticulumGitNode():
|
||||
else: client_identity = RNS.Identity.from_file(client_identity_path)
|
||||
|
||||
destination_hash = RNS.Destination.hash_from_name_and_identity(f"{APP_NAME}.repositories", self.identity)
|
||||
print(f"Git Peer Identity : {RNS.prettyhexrep(client_identity.hash)}")
|
||||
print(f"Repository Node Identity : {RNS.prettyhexrep(self.identity.hash)}")
|
||||
print(f"Repositories Destination : {RNS.prettyhexrep(destination_hash)}")
|
||||
nomadnet_hash = RNS.Destination.hash_from_name_and_identity(f"nomadnetwork.node", self.identity)
|
||||
print(f"Git Peer Identity : {RNS.prettyhexrep(client_identity.hash)}")
|
||||
print(f"Repository Node Identity : {RNS.prettyhexrep(self.identity.hash)}")
|
||||
print(f"Repositories Destination : {RNS.prettyhexrep(destination_hash)}")
|
||||
if self._serve_nomadnet: print(f"Nomad Network Destination : {RNS.prettyhexrep(nomadnet_hash)}")
|
||||
exit(0)
|
||||
|
||||
self.destination = RNS.Destination(self.identity, RNS.Destination.IN, RNS.Destination.SINGLE, APP_NAME, "repositories")
|
||||
self.destination.set_link_established_callback(self.remote_connected)
|
||||
self.register_request_handlers()
|
||||
RNS.log(f"Reticulum Git Node listening on {RNS.prettyhexrep(self.destination.hash)}", RNS.LOG_NOTICE)
|
||||
|
||||
if self._serve_nomadnet: self.page_servers["nomadnet"] = NomadNetworkNode(self)
|
||||
|
||||
self.ready = True
|
||||
|
||||
def __create_default_config(self):
|
||||
@@ -201,6 +222,25 @@ class ReticulumGitNode():
|
||||
if not os.path.isdir(self.configdir): os.makedirs(self.configdir)
|
||||
self.config.write()
|
||||
|
||||
def __load_stats(self):
|
||||
with self.stats_lock:
|
||||
self.stats = { "pages": {"front": {}}, "groups": {} }
|
||||
if not os.path.isfile(self.statspath):
|
||||
try:
|
||||
with open(self.statspath, "wb") as fh: fh.write(mp.packb(self.stats))
|
||||
except Exception as e: RNS.log(f"Could not persist stats to {self.statspath}: {e}", RNS.LOG_ERROR)
|
||||
|
||||
else:
|
||||
try:
|
||||
with open(self.statspath, "rb") as fh: self.stats = mp.unpackb(fh.read())
|
||||
except Exception as e: RNS.log(f"Could not read stats file {self.statspath}: {e}", RNS.LOG_ERROR)
|
||||
|
||||
def __persist_stats(self):
|
||||
with self.stats_lock:
|
||||
try:
|
||||
with open(self.statspath, "wb") as fh: fh.write(mp.packb(self.stats))
|
||||
except Exception as e: RNS.log(f"Could not write stats file to {self.statspath}: {e}", RNS.LOG_ERROR)
|
||||
|
||||
def __apply_config(self):
|
||||
if not os.path.isfile(self.identitypath):
|
||||
identity = RNS.Identity()
|
||||
@@ -221,11 +261,23 @@ class ReticulumGitNode():
|
||||
section = self.config["rngit"]
|
||||
if "node_name" in section: self.node_name = section["node_name"]
|
||||
if "announce_interval" in section: self.announce_interval = section.as_int("announce_interval")*60
|
||||
if "record_stats" in section: self.stats_enabled = section.as_bool("record_stats")
|
||||
if "stats_ignore_identities" in section:
|
||||
ignored = section.as_list("stats_ignore_identities")
|
||||
for identhexhash in ignored:
|
||||
if not len(identhexhash) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8*2: continue
|
||||
else:
|
||||
try: self.stats_ignored[bytes.fromhex(identhexhash)] = True
|
||||
except Exception as e: RNS.log(f"Invalid identity hash for stats ignore: {identhexhash}", RNS.LOG_WARNING)
|
||||
|
||||
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")+self.verbosity))
|
||||
|
||||
if "pages" in self.config:
|
||||
section = self.config["pages"]
|
||||
if "serve_nomadnet" in section and section.as_bool("serve_nomadnet"): self._serve_nomadnet = True
|
||||
|
||||
if "repositories" in self.config:
|
||||
section = self.config["repositories"]
|
||||
for group_name in section:
|
||||
@@ -244,12 +296,16 @@ class ReticulumGitNode():
|
||||
perm, target = self.parse_permission(entry)
|
||||
if not perm or not target: continue
|
||||
else:
|
||||
read = False; write = False
|
||||
if perm == self.PERM_READ or perm == self.PERM_READWRITE: read = True
|
||||
if perm == self.PERM_WRITE or perm == self.PERM_READWRITE: write = True
|
||||
read = False; write = False; create = False; stats = False
|
||||
if perm == self.PERM_READ or perm == self.PERM_READWRITE: read = True
|
||||
if perm == self.PERM_WRITE or perm == self.PERM_READWRITE: write = True
|
||||
if perm == self.PERM_CREATE: create = True
|
||||
if perm == self.PERM_STATS: stats = True
|
||||
|
||||
if read and not target in self.groups[group_name]["read"]: self.groups[group_name]["read"].append(target)
|
||||
if write and not target in self.groups[group_name]["write"]: self.groups[group_name]["write"].append(target)
|
||||
if read and not target in self.groups[group_name]["read"]: self.groups[group_name]["read"].append(target)
|
||||
if write and not target in self.groups[group_name]["write"]: self.groups[group_name]["write"].append(target)
|
||||
if create and not target in self.groups[group_name]["create"]: self.groups[group_name]["create"].append(target)
|
||||
if stats and not target in self.groups[group_name]["stats"]: self.groups[group_name]["stats"].append(target)
|
||||
|
||||
def parse_permission(self, permission_string):
|
||||
comps = permission_string.split(":")
|
||||
@@ -259,6 +315,8 @@ class ReticulumGitNode():
|
||||
if perm in self.PERM_R_SMPHR: perm = self.PERM_READ
|
||||
elif perm in self.PERM_W_SMPHR: perm = self.PERM_WRITE
|
||||
elif perm in self.PERM_RW_SMPHR: perm = self.PERM_READWRITE
|
||||
elif perm in self.PERM_C_SMPHR: perm = self.PERM_CREATE
|
||||
elif perm in self.PERM_S_SMPHR: perm = self.PERM_STATS
|
||||
else: perm = None
|
||||
|
||||
if target in self.TGT_NONE_SMPHR: target = self.TGT_NONE
|
||||
@@ -296,13 +354,22 @@ class ReticulumGitNode():
|
||||
repository_permissions = self.groups[group_name]["repositories"][repository_name]["write"]
|
||||
group_permissions = self.groups[group_name]["write"]
|
||||
|
||||
elif permission == self.PERM_CREATE:
|
||||
repository_permissions = self.groups[group_name]["repositories"][repository_name]["create"]
|
||||
group_permissions = self.groups[group_name]["create"]
|
||||
|
||||
elif permission == self.PERM_STATS:
|
||||
repository_permissions = self.groups[group_name]["repositories"][repository_name]["stats"]
|
||||
group_permissions = self.groups[group_name]["stats"]
|
||||
|
||||
else: return False
|
||||
|
||||
if self.TGT_NONE in repository_permissions: return False
|
||||
elif self.TGT_ALL in repository_permissions: return True
|
||||
elif remote_hash in repository_permissions: return True
|
||||
else:
|
||||
if self.TGT_NONE in group_permissions: return False
|
||||
if len(repository_permissions) > 0: return False
|
||||
elif self.TGT_NONE in group_permissions: return False
|
||||
elif self.TGT_ALL in group_permissions: return True
|
||||
elif remote_hash in group_permissions: return True
|
||||
else: return False
|
||||
@@ -313,7 +380,7 @@ class ReticulumGitNode():
|
||||
|
||||
def load_repository_group(self, group_name, group_path):
|
||||
# TODO: Implement group.allowed file
|
||||
if not group_name in self.groups: self.groups[group_name] = { "path": group_path, "repositories": {}, "read": [], "write": [] }
|
||||
if not group_name in self.groups: self.groups[group_name] = { "path": group_path, "repositories": {}, "read": [], "write": [], "create": [], "stats": [] }
|
||||
if group_name in self.groups and self.groups[group_name]["path"] != group_path:
|
||||
RNS.log(f"Repository group path did not match existing entry while loading {group_name}, aborting load", RNS.LOG_ERROR)
|
||||
return
|
||||
@@ -334,6 +401,8 @@ class ReticulumGitNode():
|
||||
allowed_path = f"{path}.allowed"
|
||||
read_allowed = []
|
||||
write_allowed = []
|
||||
create_allowed = []
|
||||
stats_allowed = []
|
||||
|
||||
if os.path.isfile(allowed_path):
|
||||
if os.access(allowed_path, os.X_OK):
|
||||
@@ -351,14 +420,20 @@ class ReticulumGitNode():
|
||||
perm, target = self.parse_permission(perm_input)
|
||||
if not perm or not target: continue
|
||||
else:
|
||||
read = False; write = False
|
||||
if perm == self.PERM_READ or perm == self.PERM_READWRITE: read = True
|
||||
if perm == self.PERM_WRITE or perm == self.PERM_READWRITE: write = True
|
||||
read = False; write = False; create = False; stats = False
|
||||
if perm == self.PERM_READ or perm == self.PERM_READWRITE: read = True
|
||||
if perm == self.PERM_WRITE or perm == self.PERM_READWRITE: write = True
|
||||
if perm == self.PERM_CREATE: create = True
|
||||
if perm == self.PERM_STATS: stats = True
|
||||
|
||||
if read and not target in read_allowed: read_allowed.append(target)
|
||||
if write and not target in write_allowed: write_allowed.append(target)
|
||||
if read and not target in read_allowed: read_allowed.append(target)
|
||||
if write and not target in write_allowed: write_allowed.append(target)
|
||||
if create and not target in create_allowed: create_allowed.append(target)
|
||||
if stats and not target in stats_allowed: stats_allowed.append(target)
|
||||
|
||||
group["repositories"][repository_name] = {"name": repository_name, "group": group_name, "path": path, "read": read_allowed, "write": write_allowed }
|
||||
group["repositories"][repository_name] = {"name": repository_name, "group": group_name, "path": path,
|
||||
"read": read_allowed, "write": write_allowed,
|
||||
"create": create_allowed, "stats": stats_allowed }
|
||||
loaded += 1
|
||||
|
||||
ms = "y" if loaded == 1 else "ies"
|
||||
@@ -377,7 +452,13 @@ class ReticulumGitNode():
|
||||
while self._should_run:
|
||||
time.sleep(self.JOBS_INTERVAL)
|
||||
try:
|
||||
if self.announce_interval and time.time() > self.last_announce + self.announce_interval: self.announce()
|
||||
if self.announce_interval and time.time() > self.last_announce + self.announce_interval:
|
||||
self.announce()
|
||||
|
||||
if time.time() > self.last_stats_job + self.stats_job_interval:
|
||||
self.__persist_stats()
|
||||
self.last_stats_job = time.time()
|
||||
|
||||
if time.time() > self.last_link_clean + self.link_clean_interval:
|
||||
stale_links = []
|
||||
with self.active_links_lock:
|
||||
@@ -501,6 +582,9 @@ class ReticulumGitNode():
|
||||
if unique_lines: output = '\n'.join(unique_lines) + f"\n@{head_ref} HEAD\n"
|
||||
else: output = f"@{head_ref} HEAD\n"
|
||||
|
||||
if for_push: self.push_succeeded(group_name, repository_name, remote_identity)
|
||||
else: self.fetch_succeeded(group_name, repository_name, remote_identity)
|
||||
|
||||
return b"\x00" + output.encode("utf-8")
|
||||
|
||||
except Exception as e:
|
||||
@@ -690,6 +774,192 @@ class ReticulumGitNode():
|
||||
RNS.log(f"Error while handling delete request for {group_name}/{repository_name}: {e}", RNS.LOG_ERROR)
|
||||
return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8")
|
||||
|
||||
def repository_stats(self, remote_identity, group_name, repository_name, lookback_days=14):
|
||||
if not self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_STATS): return None
|
||||
else:
|
||||
with self.stats_lock:
|
||||
now = time.time()
|
||||
day_seconds = 86400
|
||||
|
||||
days = []
|
||||
day_labels = []
|
||||
for i in range(lookback_days - 1, -1, -1):
|
||||
day_ts = now - (i * day_seconds)
|
||||
day_str = time.strftime("%Y-%m-%d", time.localtime(day_ts))
|
||||
days.append(day_str)
|
||||
day_labels.append(time.strftime("%b %d", time.localtime(day_ts)))
|
||||
|
||||
timeline_labels = [f"{lookback_days} days ago", "Today"]
|
||||
repo_stats = { "group": group_name, "repository": repository_name,
|
||||
"lookback_days": lookback_days, "date_range": f"{day_labels[0]} - {day_labels[-1]}",
|
||||
"days": days, "day_labels": day_labels, "timeline_labels": timeline_labels,
|
||||
"views": {"daily": [], "total": 0, "peak": 0, "peak_day": None},
|
||||
"fetches": {"daily": [], "total": 0, "peak": 0, "peak_day": None},
|
||||
"pushes": {"daily": [], "total": 0, "peak": 0, "peak_day": None} }
|
||||
|
||||
group_stats = self.stats.get("groups", {}).get(group_name, {})
|
||||
repo_data = group_stats.get("repositories", {}).get(repository_name, {})
|
||||
|
||||
view_stats = repo_data.get("view", {})
|
||||
for day in days:
|
||||
count = view_stats.get(day, 0)
|
||||
repo_stats["views"]["daily"].append(count)
|
||||
repo_stats["views"]["total"] += count
|
||||
if count > repo_stats["views"]["peak"]:
|
||||
repo_stats["views"]["peak"] = count
|
||||
repo_stats["views"]["peak_day"] = day
|
||||
|
||||
fetch_stats = repo_data.get("fetch", {})
|
||||
for day in days:
|
||||
count = fetch_stats.get(day, 0)
|
||||
repo_stats["fetches"]["daily"].append(count)
|
||||
repo_stats["fetches"]["total"] += count
|
||||
if count > repo_stats["fetches"]["peak"]:
|
||||
repo_stats["fetches"]["peak"] = count
|
||||
repo_stats["fetches"]["peak_day"] = day
|
||||
|
||||
push_stats = repo_data.get("push", {})
|
||||
for day in days:
|
||||
count = push_stats.get(day, 0)
|
||||
repo_stats["pushes"]["daily"].append(count)
|
||||
repo_stats["pushes"]["total"] += count
|
||||
if count > repo_stats["pushes"]["peak"]:
|
||||
repo_stats["pushes"]["peak"] = count
|
||||
repo_stats["pushes"]["peak_day"] = day
|
||||
|
||||
total_score = ( repo_stats["views"]["total"] * 0.2 +
|
||||
repo_stats["fetches"]["total"] * 2 +
|
||||
repo_stats["pushes"]["total"] * 5 )
|
||||
|
||||
repo_stats["activity_score"] = int(total_score)
|
||||
|
||||
actual_days = lookback_days
|
||||
all_activity_days = set()
|
||||
|
||||
for stats_dict in (view_stats, fetch_stats, push_stats):
|
||||
for day, count in stats_dict.items():
|
||||
if count > 0: all_activity_days.add(day)
|
||||
|
||||
if all_activity_days:
|
||||
earliest_day = min(all_activity_days)
|
||||
try:
|
||||
earliest_ts = time.mktime(time.strptime(earliest_day, "%Y-%m-%d"))
|
||||
span_seconds = now - earliest_ts
|
||||
actual_days = max(1, int(span_seconds // day_seconds) + 1)
|
||||
|
||||
except (ValueError, TypeError): pass
|
||||
|
||||
if actual_days > lookback_days: actual_days = lookback_days
|
||||
daily_score = total_score / actual_days if actual_days > 0 else 0
|
||||
repo_stats["actual_days"] = actual_days
|
||||
|
||||
if daily_score == 0: repo_stats["activity_level"] = "inactive"
|
||||
elif daily_score < 3: repo_stats["activity_level"] = "low"
|
||||
elif daily_score < 10: repo_stats["activity_level"] = "moderate"
|
||||
else: repo_stats["activity_level"] = "high"
|
||||
|
||||
return repo_stats
|
||||
|
||||
def view_succeeded(self, group_name, repository_name, remote_identity):
|
||||
if remote_identity and remote_identity.hash in self.stats_ignored: return
|
||||
if self.stats_enabled:
|
||||
if group_name == None and repository_name == None: self.record_page_view("front")
|
||||
elif repository_name == None: self.record_group_view(group_name)
|
||||
else: self.record_repository_view(group_name, repository_name)
|
||||
|
||||
def fetch_succeeded(self, group_name, repository_name, remote_identity):
|
||||
if remote_identity and remote_identity.hash in self.stats_ignored: return
|
||||
if self.stats_enabled:
|
||||
if group_name and repository_name: self.record_fetch(group_name, repository_name)
|
||||
|
||||
def push_succeeded(self, group_name, repository_name, remote_identity):
|
||||
if remote_identity and remote_identity.hash in self.stats_ignored: return
|
||||
if self.stats_enabled:
|
||||
if group_name and repository_name: self.record_push(group_name, repository_name)
|
||||
|
||||
def _get_day(self):
|
||||
timefmt = "%Y-%m-%d"
|
||||
timestamp = time.localtime(time.time())
|
||||
return time.strftime(timefmt, timestamp)
|
||||
|
||||
def record_page_view(self, page):
|
||||
def job():
|
||||
try:
|
||||
with self.stats_lock:
|
||||
day = self._get_day()
|
||||
if not day in self.stats["pages"]["front"]: self.stats["pages"]["front"][day] = 0
|
||||
self.stats["pages"]["front"][day] += 1
|
||||
|
||||
except Exception as e: RNS.log(f"Error while recording page view stats: {e}", RNS.LOG_ERROR)
|
||||
|
||||
threading.Thread(target=job, daemon=True).start()
|
||||
|
||||
def record_group_view(self, group_name):
|
||||
def job():
|
||||
try:
|
||||
with self.stats_lock:
|
||||
day = self._get_day()
|
||||
if not group_name in self.stats["groups"]: self.stats["groups"][group_name] = {"view": {}, "repositories": {}}
|
||||
|
||||
stats = self.stats["groups"][group_name]["view"]
|
||||
if not day in stats: stats[day] = 0
|
||||
stats[day] += 1
|
||||
|
||||
except Exception as e: RNS.log(f"Error while recording group view stats: {e}", RNS.LOG_ERROR)
|
||||
|
||||
threading.Thread(target=job, daemon=True).start()
|
||||
|
||||
def record_repository_view(self, group_name, repository_name):
|
||||
def job():
|
||||
try:
|
||||
with self.stats_lock:
|
||||
day = self._get_day()
|
||||
if not group_name in self.stats["groups"]: self.stats["groups"][group_name] = {"view": {}, "repositories": {}}
|
||||
repos = self.stats["groups"][group_name]["repositories"]
|
||||
if not repository_name in repos: repos[repository_name] = {"view": {}, "fetch": {}, "push": {}}
|
||||
|
||||
stats = repos[repository_name]["view"]
|
||||
if not day in stats: stats[day] = 0
|
||||
stats[day] += 1
|
||||
|
||||
except Exception as e: RNS.log(f"Error while recording repository view stats: {e}", RNS.LOG_ERROR)
|
||||
|
||||
threading.Thread(target=job, daemon=True).start()
|
||||
|
||||
def record_fetch(self, group_name, repository_name):
|
||||
def job():
|
||||
try:
|
||||
with self.stats_lock:
|
||||
day = self._get_day()
|
||||
if not group_name in self.stats["groups"]: self.stats["groups"][group_name] = {"view": {}, "repositories": {}}
|
||||
repos = self.stats["groups"][group_name]["repositories"]
|
||||
if not repository_name in repos: repos[repository_name] = {"view": {}, "fetch": {}, "push": {}}
|
||||
|
||||
stats = repos[repository_name]["fetch"]
|
||||
if not day in stats: stats[day] = 0
|
||||
stats[day] += 1
|
||||
|
||||
except Exception as e: RNS.log(f"Error while recording fetch stats: {e}", RNS.LOG_ERROR)
|
||||
|
||||
threading.Thread(target=job, daemon=True).start()
|
||||
|
||||
def record_push(self, group_name, repository_name):
|
||||
def job():
|
||||
try:
|
||||
with self.stats_lock:
|
||||
day = self._get_day()
|
||||
if not group_name in self.stats["groups"]: self.stats["groups"][group_name] = {"view": {}, "repositories": {}}
|
||||
repos = self.stats["groups"][group_name]["repositories"]
|
||||
if not repository_name in repos: repos[repository_name] = {"view": {}, "fetch": {}, "push": {}}
|
||||
|
||||
stats = repos[repository_name]["push"]
|
||||
if not day in stats: stats[day] = 0
|
||||
stats[day] += 1
|
||||
|
||||
except Exception as e: RNS.log(f"Error while recording push stats: {e}", RNS.LOG_ERROR)
|
||||
|
||||
threading.Thread(target=job, daemon=True).start()
|
||||
|
||||
|
||||
__default_rngit_config__ = '''# This is the default rngit config file.
|
||||
# You will need to edit it to specify repository locations and
|
||||
@@ -707,6 +977,14 @@ announce_interval = 360
|
||||
|
||||
# node_name = Anonymous Git Node
|
||||
|
||||
# You can enable collecting view, fetch and push statistics
|
||||
# which can be displayed on the stats pages of repositories.
|
||||
# Remember to set the "s" (stats) permission appropriately
|
||||
# for statistics to actually be viewable by anyone.
|
||||
|
||||
# record_stats = no
|
||||
# stats_ignore_identities = 9710b86ba12c42d1d8f30f74fe509286
|
||||
|
||||
[repositories]
|
||||
|
||||
# You can define multiple repository groups, each with a path
|
||||
@@ -716,6 +994,11 @@ internal = /path/to/directory/with/git/repositories
|
||||
public = /another/path/to/directory/with/git/repositories
|
||||
showcase = /another/path/to/directory/with/git/repositories
|
||||
|
||||
# To add a short description to your repositories, you can
|
||||
# either place a "repo_name.description" file in the same
|
||||
# directory as the repository folder, or set it in the bare
|
||||
# repository with `git config repository.description`.
|
||||
|
||||
|
||||
[access]
|
||||
|
||||
@@ -747,6 +1030,34 @@ internal = rw:9710b86ba12c42d1d8f30f74fe509286
|
||||
# functionality applies here.
|
||||
|
||||
|
||||
[pages]
|
||||
# You can run a nomadnet-compatible page node to serve
|
||||
# repository information if required. Access permissions
|
||||
# will follow those configured per group and repository.
|
||||
#
|
||||
# The page server supports automatic markdown to micron
|
||||
# conversion for repository readmes and other files. If
|
||||
# you have the pygments Python module installed, syntax
|
||||
# highlighting will also be automatically applied.
|
||||
#
|
||||
# The page server is highly customizable, and you can
|
||||
# provide custom templates for each page type by placing
|
||||
# a corresponding "template_name.mu" file in the
|
||||
# ~/.rngit/templates directory. The supported template
|
||||
# names are "base", "front", "group", "repo", "tree",
|
||||
# "blob", "commits", "commit", "refs" and "stats". You
|
||||
# should include a {PAGE_CONTENT} variable somewhere in
|
||||
# your templates, the rendered page content will be
|
||||
# injected into this variable.
|
||||
|
||||
# serve_nomadnet = no
|
||||
|
||||
# It is possible to disable Nerd Font icons and instead
|
||||
# use simpler (but more compatible) unicode icons.
|
||||
|
||||
# unicode_icons = yes
|
||||
|
||||
|
||||
[logging]
|
||||
# Valid log levels are 0 through 7:
|
||||
# 0: Log only critical information
|
||||
|
||||
@@ -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)
|
||||
+1
-1
@@ -1 +1 @@
|
||||
__version__ = "1.2.0"
|
||||
__version__ = "1.2.1"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
# Sphinx build info version 1
|
||||
# This file records the configuration used when building these files. When it is not found, a full rebuild will be done.
|
||||
config: f2cc470cf3dcb5532458b5e114aa5d26
|
||||
config: 30a85b1d5fe4a94e60d7f412c97b4772
|
||||
tags: 645f666f9bcd5a90fca523b33c5a78b7
|
||||
|
||||
@@ -687,7 +687,7 @@ The ``rngit`` utility provides full Git repository hosting and interaction over
|
||||
|
||||
The system consists of two parts: The ``rngit`` node that hosts repositories, and the ``git-remote-rns`` helper that enables Git to communicate with rngit nodes. As soon as you have RNS installed on your system, you can transparently use Git with Reticulum-hosted repositories just like any other type of remote. Git over Reticulum uses URLs in the following format: ``rns://DESTINATION_HASH/group/repo``.
|
||||
|
||||
If you set a branch to track a Reticulum remote as the default upstream, you can simply use `git` as you normally would; all commands work transparently and as expected.
|
||||
If you set a branch to track a Reticulum remote as the default upstream, you can simply use ``git`` as you normally would; all commands work transparently and as expected.
|
||||
|
||||
.. warning::
|
||||
**The rngit program is a new addition to RNS!** This functionality was introduced in RNS 1.2.0. While great care has been taken to design a secure, but highly configurable and flexible permission system for allowing many users to interact with many different repositories on a single node, ``rngit`` has not been tested extensively in the wild! Be careful when hosting repositories, especially if they are public or semi-public.
|
||||
@@ -711,9 +711,11 @@ View your identity and destination hashes:
|
||||
|
||||
$ rngit --print-identity
|
||||
|
||||
Git Peer Identity : <959e10e5efc1bd9d97a4083babe51dea>
|
||||
Repository Node Identity : <153cb870b4665b8c1c348896292b0bad>
|
||||
Repositories Destination : <0d7334d411d00120cbad24edf355fdd2>
|
||||
Git Peer Identity : <959e10e5efc1bd9d97a4083babe51dea>
|
||||
Repository Node Identity : <153cb870b4665b8c1c348896292b0bad>
|
||||
Repositories Destination : <0d7334d411d00120cbad24edf355fdd2>
|
||||
|
||||
If the page server is enabled, the output will also include the Nomad Network destination hash.
|
||||
|
||||
You can run ``rngit`` in service mode with logging to file:
|
||||
|
||||
@@ -756,8 +758,11 @@ The ``rngit`` node configuration file is located at ``~/.rngit/config`` (or ``/e
|
||||
- Repository group paths defining where to find bare repositories
|
||||
- Access permissions for groups and individual repositories
|
||||
- Announce intervals for network visibility
|
||||
- Optional statistics recording for repository activity
|
||||
|
||||
Access permissions can be configured at the group level in the config file, or per-repository using ``.allowed`` files. Permissions use the format ``permission:target`` where permission is ``r`` (read), ``w`` (write), or ``rw`` (read/write), and target is ``all``, ``none``, or a specific identity hash.
|
||||
Access permissions can be configured at the group level in the config file, or per-repository using ``.allowed`` files. Permissions use the format ``permission:target`` where permission is ``r`` (read), ``w`` (write), ``rw`` (read/write), ``c`` (create) or ``s`` (stats) and target is ``all``, ``none``, or a specific identity hash.
|
||||
|
||||
The ``s`` (stats) permission allows viewing repository activity statistics, including views, fetches and pushes over time. To enable statistics recording, set ``record_stats = yes`` in the ``[rngit]`` section of the configuration file. You can also exclude specific identities from statistics by adding their hashes to ``stats_ignore_identities``.
|
||||
|
||||
Repository-specific ``.allowed`` files can be static text files or executable scripts that output permission rules to stdout. A ``group.allowed`` file in a repository group directory applies to all repositories within that group.
|
||||
|
||||
@@ -791,6 +796,146 @@ The ``git-remote-rns`` helper is automatically invoked by Git when interacting w
|
||||
|
||||
The client configuration file is located at ``~/.rngit/client_config`` and allows adjusting parameters such as the reference batch size for transfers.
|
||||
|
||||
**Serving Pages Over Nomad Network**
|
||||
|
||||
In addition to providing Git repository access via the Git remote helper protocol, ``rngit`` can also run a `Nomad Network <https://github.com/markqvist/nomadnet>`_ compatible page node. This allows users to browse repository information, view file contents, inspect commit history and access repository statistics through any Nomad Network client.
|
||||
|
||||
When enabled, the page node provides a complete interface to your repositories, with automatic Markdown to Micron conversion, syntax-highlighted code browsing, and detailed commit, diff and statistics views.
|
||||
|
||||
**Enabling the Git Page Node**
|
||||
|
||||
To enable the page node, add the following to your ``~/.rngit/config`` file:
|
||||
|
||||
.. code:: text
|
||||
|
||||
[pages]
|
||||
serve_nomadnet = yes
|
||||
|
||||
When the page node is enabled, ``rngit`` will listen on a Nomad Network node destination in addition to the Git repository destination. You can view the destination hash by running:
|
||||
|
||||
.. code:: text
|
||||
|
||||
$ rngit --print-identity
|
||||
|
||||
Git Peer Identity : <959e10e5efc1bd9d97a4083babe51dea>
|
||||
Repository Node Identity : <153cb870b4665b8c1c348896292b0bad>
|
||||
Repositories Destination : <0d7334d411d00120cbad24edf355fdd2>
|
||||
Nomad Network Destination : <50824b711717f97c2fb1166ceddd5ea9>
|
||||
|
||||
**Accessing Repository Pages**
|
||||
|
||||
Once the page server is running, you can access it from any Nomad Network client by connecting to the Nomad Network destination. The page node provides the following views:
|
||||
|
||||
- **Front Page** - Lists all repository groups accessible to your identity
|
||||
- **Group Page** - Shows all repositories within a group
|
||||
- **Repository Page** - Displays repository overview, description and README
|
||||
- **File Browser** - Browse directory trees and view file contents
|
||||
- **Commits View** - View commit history with pagination
|
||||
- **Commit Details** - Detailed commit information with file changes and diffs
|
||||
- **Refs View** - List branches and tags
|
||||
- **Statistics** - Activity charts showing views, fetches and pushes over time
|
||||
|
||||
All pages respect the same permission system used for Git access. If an identity does not have read access to a repository, they will not be able to view its pages.
|
||||
|
||||
**Syntax Highlighting**
|
||||
|
||||
If the ``pygments`` Python module is installed on your system, the page server will automatically apply syntax highlighting to code files. The highlighting supports a wide range of programming languages and uses a color theme optimized for terminal display.
|
||||
|
||||
To enable syntax highlighting, install pygments:
|
||||
|
||||
.. code:: text
|
||||
|
||||
pip install pygments
|
||||
|
||||
**Markdown & Micron Support**
|
||||
|
||||
README files and other Markdown documents are automatically converted to Micron markup for display in Nomad Network clients. You can also write your README files directly in Micron, in which case they will display and render as such in any Nomad Network client. The file browser also supports viewing both rendered and raw Markdown and Micron documents.
|
||||
|
||||
Code blocks in Markdown can include language hints for syntax highlighting:
|
||||
|
||||
.. code:: text
|
||||
|
||||
```python
|
||||
def hello_world():
|
||||
print("Hello, Reticulum!")
|
||||
```
|
||||
|
||||
**Customizing Templates**
|
||||
|
||||
The page server uses a template system that allows complete customization of the generated pages. Templates are stored in the ``~/.rngit/templates/`` directory as Micron files.
|
||||
|
||||
The following template files are supported:
|
||||
|
||||
- ``base.mu`` - Base template wrapping all pages (must include ``{PAGE_CONTENT}``)
|
||||
- ``front.mu`` - Front page listing all groups
|
||||
- ``group.mu`` - Group page listing repositories
|
||||
- ``repo.mu`` - Repository overview page
|
||||
- ``tree.mu`` - File browser pages
|
||||
- ``blob.mu`` - File content display
|
||||
- ``commits.mu`` - Commit history listing
|
||||
- ``commit.mu`` - Individual commit detail page
|
||||
- ``refs.mu`` - Branches and tags listing
|
||||
- ``stats.mu`` - Statistics page
|
||||
|
||||
Templates can include the following variables:
|
||||
|
||||
- ``{PAGE_CONTENT}`` - The main content of the page (required)
|
||||
- ``{NODE_NAME}`` - The configured node name
|
||||
- ``{NAVIGATION}`` - Breadcrumb navigation links
|
||||
- ``{VERSION}`` - The rngit version number
|
||||
- ``{GEN_TIME}`` - Page generation time
|
||||
|
||||
**Dynamic Templates**
|
||||
|
||||
Templates can be made executable to generate dynamic content. If a template file has the executable bit set, it will be executed and its stdout used as the template content.
|
||||
|
||||
**Icon Sets**
|
||||
|
||||
By default, the page server uses Nerd Font icons. If you prefer simpler icons or your terminal does not support Nerd Fonts, you can enable Unicode icons instead:
|
||||
|
||||
.. code:: text
|
||||
|
||||
[pages]
|
||||
serve_nomadnet = yes
|
||||
unicode_icons = yes
|
||||
|
||||
**Repository Statistics**
|
||||
|
||||
When statistics recording is enabled (see the ``record_stats`` configuration option), the page server can display activity charts for each repository. The statistics page shows:
|
||||
|
||||
- Total and peak views, fetches and pushes
|
||||
- Daily activity charts over a 90-day period
|
||||
- Combined activity visualization
|
||||
|
||||
To view statistics, a user must have the ``s`` (stats) permission for the repository. See the Access Configuration section for details on setting permissions.
|
||||
|
||||
**Repository Thanks**
|
||||
|
||||
The page server includes a "Thanks" feature that allows users to express appreciation for a repository. On each repository page, a "Thanks" link is displayed showing the current thanks count. Clicking this link registers a thank you for the repository.
|
||||
|
||||
**Configuration Example**
|
||||
|
||||
A complete page server configuration might look like this:
|
||||
|
||||
.. code:: text
|
||||
|
||||
[rngit]
|
||||
node_name = My Git Server
|
||||
announce_interval = 360
|
||||
record_stats = yes
|
||||
|
||||
[repositories]
|
||||
public = /var/git/public
|
||||
internal = /var/git/internal
|
||||
|
||||
[access]
|
||||
public = r:all
|
||||
internal = rw:9710b86ba12c42d1d8f30f74fe509286
|
||||
|
||||
[pages]
|
||||
serve_nomadnet = yes
|
||||
unicode_icons = no
|
||||
|
||||
|
||||
The rnx Utility
|
||||
================
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const DOCUMENTATION_OPTIONS = {
|
||||
VERSION: '1.2.0',
|
||||
VERSION: '1.2.1',
|
||||
LANGUAGE: 'en',
|
||||
COLLAPSE_INDEX: false,
|
||||
BUILDER: 'html',
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
|
||||
|
||||
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
|
||||
<title>Code Examples - Reticulum Network Stack 1.2.0 documentation</title>
|
||||
<title>Code Examples - Reticulum Network Stack 1.2.1 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
|
||||
@@ -180,7 +180,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.0 documentation</div></a>
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.1 documentation</div></a>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="theme-toggle-container theme-toggle-header">
|
||||
@@ -204,7 +204,7 @@
|
||||
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
|
||||
</div>
|
||||
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.0 documentation</span>
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.1 documentation</span>
|
||||
|
||||
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
|
||||
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
|
||||
@@ -3663,7 +3663,7 @@ will be fully on-par with natively included interfaces, including all supported
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div><script src="_static/documentation_options.js?v=6efca38a"></script>
|
||||
</div><script src="_static/documentation_options.js?v=ca842793"></script>
|
||||
<script src="_static/doctools.js?v=9bcbadda"></script>
|
||||
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
|
||||
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
|
||||
|
||||
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
|
||||
<title>An Explanation of Reticulum for Human Beings - Reticulum Network Stack 1.2.0 documentation</title>
|
||||
<title>An Explanation of Reticulum for Human Beings - Reticulum Network Stack 1.2.1 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
|
||||
@@ -180,7 +180,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.0 documentation</div></a>
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.1 documentation</div></a>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="theme-toggle-container theme-toggle-header">
|
||||
@@ -204,7 +204,7 @@
|
||||
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
|
||||
</div>
|
||||
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.0 documentation</span>
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.1 documentation</span>
|
||||
|
||||
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
|
||||
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
|
||||
@@ -294,7 +294,7 @@
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div><script src="_static/documentation_options.js?v=6efca38a"></script>
|
||||
</div><script src="_static/documentation_options.js?v=ca842793"></script>
|
||||
<script src="_static/doctools.js?v=9bcbadda"></script>
|
||||
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
|
||||
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="color-scheme" content="light dark"><link rel="index" title="Index" href="#"><link rel="search" title="Search" href="search.html">
|
||||
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
|
||||
|
||||
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 --><title>Index - Reticulum Network Stack 1.2.0 documentation</title>
|
||||
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 --><title>Index - Reticulum Network Stack 1.2.1 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
|
||||
@@ -178,7 +178,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.0 documentation</div></a>
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.1 documentation</div></a>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="theme-toggle-container theme-toggle-header">
|
||||
@@ -202,7 +202,7 @@
|
||||
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
|
||||
</div>
|
||||
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.0 documentation</span>
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.1 documentation</span>
|
||||
|
||||
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
|
||||
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
|
||||
@@ -836,7 +836,7 @@
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div><script src="_static/documentation_options.js?v=6efca38a"></script>
|
||||
</div><script src="_static/documentation_options.js?v=ca842793"></script>
|
||||
<script src="_static/doctools.js?v=9bcbadda"></script>
|
||||
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
|
||||
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
|
||||
|
||||
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
|
||||
<title>Getting Started Fast - Reticulum Network Stack 1.2.0 documentation</title>
|
||||
<title>Getting Started Fast - Reticulum Network Stack 1.2.1 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
|
||||
@@ -180,7 +180,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.0 documentation</div></a>
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.1 documentation</div></a>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="theme-toggle-container theme-toggle-header">
|
||||
@@ -204,7 +204,7 @@
|
||||
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
|
||||
</div>
|
||||
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.0 documentation</span>
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.1 documentation</span>
|
||||
|
||||
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
|
||||
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
|
||||
@@ -966,7 +966,7 @@ All other available modules will still be loaded when needed.</p>
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div><script src="_static/documentation_options.js?v=6efca38a"></script>
|
||||
</div><script src="_static/documentation_options.js?v=ca842793"></script>
|
||||
<script src="_static/doctools.js?v=9bcbadda"></script>
|
||||
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
|
||||
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
|
||||
|
||||
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
|
||||
<title>Communications Hardware - Reticulum Network Stack 1.2.0 documentation</title>
|
||||
<title>Communications Hardware - Reticulum Network Stack 1.2.1 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
|
||||
@@ -180,7 +180,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.0 documentation</div></a>
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.1 documentation</div></a>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="theme-toggle-container theme-toggle-header">
|
||||
@@ -204,7 +204,7 @@
|
||||
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
|
||||
</div>
|
||||
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.0 documentation</span>
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.1 documentation</span>
|
||||
|
||||
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
|
||||
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
|
||||
@@ -674,7 +674,7 @@ can be used with Reticulum. This includes virtual software modems such as
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div><script src="_static/documentation_options.js?v=6efca38a"></script>
|
||||
</div><script src="_static/documentation_options.js?v=ca842793"></script>
|
||||
<script src="_static/doctools.js?v=9bcbadda"></script>
|
||||
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
|
||||
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
|
||||
|
||||
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
|
||||
<title>Reticulum Network Stack 1.2.0 documentation</title>
|
||||
<title>Reticulum Network Stack 1.2.1 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
|
||||
@@ -180,7 +180,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<a href="#"><div class="brand">Reticulum Network Stack 1.2.0 documentation</div></a>
|
||||
<a href="#"><div class="brand">Reticulum Network Stack 1.2.1 documentation</div></a>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="theme-toggle-container theme-toggle-header">
|
||||
@@ -204,7 +204,7 @@
|
||||
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
|
||||
</div>
|
||||
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.0 documentation</span>
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.1 documentation</span>
|
||||
|
||||
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
|
||||
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
|
||||
@@ -633,7 +633,7 @@ to participate in the development of Reticulum itself.</p>
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div><script src="_static/documentation_options.js?v=6efca38a"></script>
|
||||
</div><script src="_static/documentation_options.js?v=ca842793"></script>
|
||||
<script src="_static/doctools.js?v=9bcbadda"></script>
|
||||
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
|
||||
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
|
||||
|
||||
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
|
||||
<title>Configuring Interfaces - Reticulum Network Stack 1.2.0 documentation</title>
|
||||
<title>Configuring Interfaces - Reticulum Network Stack 1.2.1 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
|
||||
@@ -180,7 +180,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.0 documentation</div></a>
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.1 documentation</div></a>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="theme-toggle-container theme-toggle-header">
|
||||
@@ -204,7 +204,7 @@
|
||||
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
|
||||
</div>
|
||||
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.0 documentation</span>
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.1 documentation</span>
|
||||
|
||||
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
|
||||
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
|
||||
@@ -1684,7 +1684,7 @@ to <code class="docutils literal notranslate"><span class="pre">30</span></code>
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div><script src="_static/documentation_options.js?v=6efca38a"></script>
|
||||
</div><script src="_static/documentation_options.js?v=ca842793"></script>
|
||||
<script src="_static/doctools.js?v=9bcbadda"></script>
|
||||
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
|
||||
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
|
||||
|
||||
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
|
||||
<title>Reticulum License - Reticulum Network Stack 1.2.0 documentation</title>
|
||||
<title>Reticulum License - Reticulum Network Stack 1.2.1 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
|
||||
@@ -180,7 +180,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.0 documentation</div></a>
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.1 documentation</div></a>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="theme-toggle-container theme-toggle-header">
|
||||
@@ -204,7 +204,7 @@
|
||||
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
|
||||
</div>
|
||||
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.0 documentation</span>
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.1 documentation</span>
|
||||
|
||||
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
|
||||
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
|
||||
@@ -343,7 +343,7 @@ SOFTWARE.
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div><script src="_static/documentation_options.js?v=6efca38a"></script>
|
||||
</div><script src="_static/documentation_options.js?v=ca842793"></script>
|
||||
<script src="_static/doctools.js?v=9bcbadda"></script>
|
||||
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
|
||||
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
|
||||
|
||||
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
|
||||
<title>Building Networks - Reticulum Network Stack 1.2.0 documentation</title>
|
||||
<title>Building Networks - Reticulum Network Stack 1.2.1 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
|
||||
@@ -180,7 +180,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.0 documentation</div></a>
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.1 documentation</div></a>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="theme-toggle-container theme-toggle-header">
|
||||
@@ -204,7 +204,7 @@
|
||||
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
|
||||
</div>
|
||||
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.0 documentation</span>
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.1 documentation</span>
|
||||
|
||||
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
|
||||
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
|
||||
@@ -662,7 +662,7 @@ differently than a mobile device roaming between radio cells.</p>
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div><script src="_static/documentation_options.js?v=6efca38a"></script>
|
||||
</div><script src="_static/documentation_options.js?v=ca842793"></script>
|
||||
<script src="_static/doctools.js?v=9bcbadda"></script>
|
||||
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
|
||||
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@
|
||||
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
|
||||
|
||||
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
|
||||
<title>API Reference - Reticulum Network Stack 1.2.0 documentation</title>
|
||||
<title>API Reference - Reticulum Network Stack 1.2.1 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
|
||||
@@ -180,7 +180,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.0 documentation</div></a>
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.1 documentation</div></a>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="theme-toggle-container theme-toggle-header">
|
||||
@@ -204,7 +204,7 @@
|
||||
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
|
||||
</div>
|
||||
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.0 documentation</span>
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.1 documentation</span>
|
||||
|
||||
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
|
||||
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
|
||||
@@ -2472,7 +2472,7 @@ will announce it.</p>
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div><script src="_static/documentation_options.js?v=6efca38a"></script>
|
||||
</div><script src="_static/documentation_options.js?v=ca842793"></script>
|
||||
<script src="_static/doctools.js?v=9bcbadda"></script>
|
||||
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
|
||||
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>Search - Reticulum Network Stack 1.2.0 documentation</title><link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
|
||||
<title>Search - Reticulum Network Stack 1.2.1 documentation</title><link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/styles/furo-extensions.css?v=8dab3a3b" />
|
||||
@@ -180,7 +180,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.0 documentation</div></a>
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.1 documentation</div></a>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="theme-toggle-container theme-toggle-header">
|
||||
@@ -204,7 +204,7 @@
|
||||
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
|
||||
</div>
|
||||
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.0 documentation</span>
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.1 documentation</span>
|
||||
|
||||
</a><form class="sidebar-search-container" method="get" action="#" role="search">
|
||||
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
|
||||
@@ -302,7 +302,7 @@
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div><script src="_static/documentation_options.js?v=6efca38a"></script>
|
||||
</div><script src="_static/documentation_options.js?v=ca842793"></script>
|
||||
<script src="_static/doctools.js?v=9bcbadda"></script>
|
||||
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
|
||||
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@
|
||||
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
|
||||
|
||||
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
|
||||
<title>Programs Using Reticulum - Reticulum Network Stack 1.2.0 documentation</title>
|
||||
<title>Programs Using Reticulum - Reticulum Network Stack 1.2.1 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
|
||||
@@ -180,7 +180,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.0 documentation</div></a>
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.1 documentation</div></a>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="theme-toggle-container theme-toggle-header">
|
||||
@@ -204,7 +204,7 @@
|
||||
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
|
||||
</div>
|
||||
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.0 documentation</span>
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.1 documentation</span>
|
||||
|
||||
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
|
||||
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
|
||||
@@ -533,7 +533,7 @@ using LXMF.</p>
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div><script src="_static/documentation_options.js?v=6efca38a"></script>
|
||||
</div><script src="_static/documentation_options.js?v=ca842793"></script>
|
||||
<script src="_static/doctools.js?v=9bcbadda"></script>
|
||||
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
|
||||
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
|
||||
|
||||
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
|
||||
<title>Support Reticulum - Reticulum Network Stack 1.2.0 documentation</title>
|
||||
<title>Support Reticulum - Reticulum Network Stack 1.2.1 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
|
||||
@@ -180,7 +180,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.0 documentation</div></a>
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.1 documentation</div></a>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="theme-toggle-container theme-toggle-header">
|
||||
@@ -204,7 +204,7 @@
|
||||
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
|
||||
</div>
|
||||
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.0 documentation</span>
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.1 documentation</span>
|
||||
|
||||
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
|
||||
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
|
||||
@@ -381,7 +381,7 @@ circumstances, so we rely on old-fashioned human feedback.</p>
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div><script src="_static/documentation_options.js?v=6efca38a"></script>
|
||||
</div><script src="_static/documentation_options.js?v=ca842793"></script>
|
||||
<script src="_static/doctools.js?v=9bcbadda"></script>
|
||||
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
|
||||
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
|
||||
|
||||
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
|
||||
<title>Understanding Reticulum - Reticulum Network Stack 1.2.0 documentation</title>
|
||||
<title>Understanding Reticulum - Reticulum Network Stack 1.2.1 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
|
||||
@@ -180,7 +180,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.0 documentation</div></a>
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.1 documentation</div></a>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="theme-toggle-container theme-toggle-header">
|
||||
@@ -204,7 +204,7 @@
|
||||
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
|
||||
</div>
|
||||
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.0 documentation</span>
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.1 documentation</span>
|
||||
|
||||
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
|
||||
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
|
||||
@@ -1336,7 +1336,7 @@ those risks are acceptable to you.</p>
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div><script src="_static/documentation_options.js?v=6efca38a"></script>
|
||||
</div><script src="_static/documentation_options.js?v=ca842793"></script>
|
||||
<script src="_static/doctools.js?v=9bcbadda"></script>
|
||||
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
|
||||
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
|
||||
|
||||
+120
-9
@@ -7,7 +7,7 @@
|
||||
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
|
||||
|
||||
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
|
||||
<title>Using Reticulum on Your System - Reticulum Network Stack 1.2.0 documentation</title>
|
||||
<title>Using Reticulum on Your System - Reticulum Network Stack 1.2.1 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
|
||||
@@ -180,7 +180,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.0 documentation</div></a>
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.1 documentation</div></a>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="theme-toggle-container theme-toggle-header">
|
||||
@@ -204,7 +204,7 @@
|
||||
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
|
||||
</div>
|
||||
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.0 documentation</span>
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.1 documentation</span>
|
||||
|
||||
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
|
||||
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
|
||||
@@ -854,7 +854,7 @@ options:
|
||||
<h3>The rngit Utility<a class="headerlink" href="#the-rngit-utility" title="Link to this heading">¶</a></h3>
|
||||
<p>The <code class="docutils literal notranslate"><span class="pre">rngit</span></code> utility provides full Git repository hosting and interaction over Reticulum. It allows you to host Git repositories on Reticulum nodes, and to interact with remote repositories using standard Git commands through the <code class="docutils literal notranslate"><span class="pre">rns://</span></code> URL scheme.</p>
|
||||
<p>The system consists of two parts: The <code class="docutils literal notranslate"><span class="pre">rngit</span></code> node that hosts repositories, and the <code class="docutils literal notranslate"><span class="pre">git-remote-rns</span></code> helper that enables Git to communicate with rngit nodes. As soon as you have RNS installed on your system, you can transparently use Git with Reticulum-hosted repositories just like any other type of remote. Git over Reticulum uses URLs in the following format: <code class="docutils literal notranslate"><span class="pre">rns://DESTINATION_HASH/group/repo</span></code>.</p>
|
||||
<p>If you set a branch to track a Reticulum remote as the default upstream, you can simply use <cite>git</cite> as you normally would; all commands work transparently and as expected.</p>
|
||||
<p>If you set a branch to track a Reticulum remote as the default upstream, you can simply use <code class="docutils literal notranslate"><span class="pre">git</span></code> as you normally would; all commands work transparently and as expected.</p>
|
||||
<div class="admonition warning">
|
||||
<p class="admonition-title">Warning</p>
|
||||
<p><strong>The rngit program is a new addition to RNS!</strong> This functionality was introduced in RNS 1.2.0. While great care has been taken to design a secure, but highly configurable and flexible permission system for allowing many users to interact with many different repositories on a single node, <code class="docutils literal notranslate"><span class="pre">rngit</span></code> has not been tested extensively in the wild! Be careful when hosting repositories, especially if they are public or semi-public.</p>
|
||||
@@ -871,11 +871,12 @@ options:
|
||||
<p>View your identity and destination hashes:</p>
|
||||
<div class="highlight-text notranslate"><div class="highlight"><pre><span></span>$ rngit --print-identity
|
||||
|
||||
Git Peer Identity : <959e10e5efc1bd9d97a4083babe51dea>
|
||||
Repository Node Identity : <153cb870b4665b8c1c348896292b0bad>
|
||||
Repositories Destination : <0d7334d411d00120cbad24edf355fdd2>
|
||||
Git Peer Identity : <959e10e5efc1bd9d97a4083babe51dea>
|
||||
Repository Node Identity : <153cb870b4665b8c1c348896292b0bad>
|
||||
Repositories Destination : <0d7334d411d00120cbad24edf355fdd2>
|
||||
</pre></div>
|
||||
</div>
|
||||
<p>If the page server is enabled, the output will also include the Nomad Network destination hash.</p>
|
||||
<p>You can run <code class="docutils literal notranslate"><span class="pre">rngit</span></code> in service mode with logging to file:</p>
|
||||
<div class="highlight-text notranslate"><div class="highlight"><pre><span></span>$ rngit -s
|
||||
</pre></div>
|
||||
@@ -904,8 +905,10 @@ Repositories Destination : <0d7334d411d00120cbad24edf355fdd2>
|
||||
<li><p>Repository group paths defining where to find bare repositories</p></li>
|
||||
<li><p>Access permissions for groups and individual repositories</p></li>
|
||||
<li><p>Announce intervals for network visibility</p></li>
|
||||
<li><p>Optional statistics recording for repository activity</p></li>
|
||||
</ul>
|
||||
<p>Access permissions can be configured at the group level in the config file, or per-repository using <code class="docutils literal notranslate"><span class="pre">.allowed</span></code> files. Permissions use the format <code class="docutils literal notranslate"><span class="pre">permission:target</span></code> where permission is <code class="docutils literal notranslate"><span class="pre">r</span></code> (read), <code class="docutils literal notranslate"><span class="pre">w</span></code> (write), or <code class="docutils literal notranslate"><span class="pre">rw</span></code> (read/write), and target is <code class="docutils literal notranslate"><span class="pre">all</span></code>, <code class="docutils literal notranslate"><span class="pre">none</span></code>, or a specific identity hash.</p>
|
||||
<p>Access permissions can be configured at the group level in the config file, or per-repository using <code class="docutils literal notranslate"><span class="pre">.allowed</span></code> files. Permissions use the format <code class="docutils literal notranslate"><span class="pre">permission:target</span></code> where permission is <code class="docutils literal notranslate"><span class="pre">r</span></code> (read), <code class="docutils literal notranslate"><span class="pre">w</span></code> (write), <code class="docutils literal notranslate"><span class="pre">rw</span></code> (read/write), <code class="docutils literal notranslate"><span class="pre">c</span></code> (create) or <code class="docutils literal notranslate"><span class="pre">s</span></code> (stats) and target is <code class="docutils literal notranslate"><span class="pre">all</span></code>, <code class="docutils literal notranslate"><span class="pre">none</span></code>, or a specific identity hash.</p>
|
||||
<p>The <code class="docutils literal notranslate"><span class="pre">s</span></code> (stats) permission allows viewing repository activity statistics, including views, fetches and pushes over time. To enable statistics recording, set <code class="docutils literal notranslate"><span class="pre">record_stats</span> <span class="pre">=</span> <span class="pre">yes</span></code> in the <code class="docutils literal notranslate"><span class="pre">[rngit]</span></code> section of the configuration file. You can also exclude specific identities from statistics by adding their hashes to <code class="docutils literal notranslate"><span class="pre">stats_ignore_identities</span></code>.</p>
|
||||
<p>Repository-specific <code class="docutils literal notranslate"><span class="pre">.allowed</span></code> files can be static text files or executable scripts that output permission rules to stdout. A <code class="docutils literal notranslate"><span class="pre">group.allowed</span></code> file in a repository group directory applies to all repositories within that group.</p>
|
||||
<p><strong>All Command-Line Options (rngit)</strong></p>
|
||||
<div class="highlight-text notranslate"><div class="highlight"><pre><span></span>usage: rngit.py [-h] [--config CONFIG] [--rnsconfig RNSCONFIG] [-s] [-i] [-v]
|
||||
@@ -933,6 +936,114 @@ options:
|
||||
<li><p><code class="docutils literal notranslate"><span class="pre">RNS_CONFIG</span></code> - Path to alternative Reticulum configuration directory</p></li>
|
||||
</ul>
|
||||
<p>The client configuration file is located at <code class="docutils literal notranslate"><span class="pre">~/.rngit/client_config</span></code> and allows adjusting parameters such as the reference batch size for transfers.</p>
|
||||
<p><strong>Serving Pages Over Nomad Network</strong></p>
|
||||
<p>In addition to providing Git repository access via the Git remote helper protocol, <code class="docutils literal notranslate"><span class="pre">rngit</span></code> can also run a <a class="reference external" href="https://github.com/markqvist/nomadnet">Nomad Network</a> compatible page node. This allows users to browse repository information, view file contents, inspect commit history and access repository statistics through any Nomad Network client.</p>
|
||||
<p>When enabled, the page node provides a complete interface to your repositories, with automatic Markdown to Micron conversion, syntax-highlighted code browsing, and detailed commit, diff and statistics views.</p>
|
||||
<p><strong>Enabling the Git Page Node</strong></p>
|
||||
<p>To enable the page node, add the following to your <code class="docutils literal notranslate"><span class="pre">~/.rngit/config</span></code> file:</p>
|
||||
<div class="highlight-text notranslate"><div class="highlight"><pre><span></span>[pages]
|
||||
serve_nomadnet = yes
|
||||
</pre></div>
|
||||
</div>
|
||||
<p>When the page node is enabled, <code class="docutils literal notranslate"><span class="pre">rngit</span></code> will listen on a Nomad Network node destination in addition to the Git repository destination. You can view the destination hash by running:</p>
|
||||
<div class="highlight-text notranslate"><div class="highlight"><pre><span></span>$ rngit --print-identity
|
||||
|
||||
Git Peer Identity : <959e10e5efc1bd9d97a4083babe51dea>
|
||||
Repository Node Identity : <153cb870b4665b8c1c348896292b0bad>
|
||||
Repositories Destination : <0d7334d411d00120cbad24edf355fdd2>
|
||||
Nomad Network Destination : <50824b711717f97c2fb1166ceddd5ea9>
|
||||
</pre></div>
|
||||
</div>
|
||||
<p><strong>Accessing Repository Pages</strong></p>
|
||||
<p>Once the page server is running, you can access it from any Nomad Network client by connecting to the Nomad Network destination. The page node provides the following views:</p>
|
||||
<ul class="simple">
|
||||
<li><p><strong>Front Page</strong> - Lists all repository groups accessible to your identity</p></li>
|
||||
<li><p><strong>Group Page</strong> - Shows all repositories within a group</p></li>
|
||||
<li><p><strong>Repository Page</strong> - Displays repository overview, description and README</p></li>
|
||||
<li><p><strong>File Browser</strong> - Browse directory trees and view file contents</p></li>
|
||||
<li><p><strong>Commits View</strong> - View commit history with pagination</p></li>
|
||||
<li><p><strong>Commit Details</strong> - Detailed commit information with file changes and diffs</p></li>
|
||||
<li><p><strong>Refs View</strong> - List branches and tags</p></li>
|
||||
<li><p><strong>Statistics</strong> - Activity charts showing views, fetches and pushes over time</p></li>
|
||||
</ul>
|
||||
<p>All pages respect the same permission system used for Git access. If an identity does not have read access to a repository, they will not be able to view its pages.</p>
|
||||
<p><strong>Syntax Highlighting</strong></p>
|
||||
<p>If the <code class="docutils literal notranslate"><span class="pre">pygments</span></code> Python module is installed on your system, the page server will automatically apply syntax highlighting to code files. The highlighting supports a wide range of programming languages and uses a color theme optimized for terminal display.</p>
|
||||
<p>To enable syntax highlighting, install pygments:</p>
|
||||
<div class="highlight-text notranslate"><div class="highlight"><pre><span></span>pip install pygments
|
||||
</pre></div>
|
||||
</div>
|
||||
<p><strong>Markdown & Micron Support</strong></p>
|
||||
<p>README files and other Markdown documents are automatically converted to Micron markup for display in Nomad Network clients. You can also write your README files directly in Micron, in which case they will display and render as such in any Nomad Network client. The file browser also supports viewing both rendered and raw Markdown and Micron documents.</p>
|
||||
<p>Code blocks in Markdown can include language hints for syntax highlighting:</p>
|
||||
<div class="highlight-text notranslate"><div class="highlight"><pre><span></span>```python
|
||||
def hello_world():
|
||||
print("Hello, Reticulum!")
|
||||
```
|
||||
</pre></div>
|
||||
</div>
|
||||
<p><strong>Customizing Templates</strong></p>
|
||||
<p>The page server uses a template system that allows complete customization of the generated pages. Templates are stored in the <code class="docutils literal notranslate"><span class="pre">~/.rngit/templates/</span></code> directory as Micron files.</p>
|
||||
<p>The following template files are supported:</p>
|
||||
<ul class="simple">
|
||||
<li><p><code class="docutils literal notranslate"><span class="pre">base.mu</span></code> - Base template wrapping all pages (must include <code class="docutils literal notranslate"><span class="pre">{PAGE_CONTENT}</span></code>)</p></li>
|
||||
<li><p><code class="docutils literal notranslate"><span class="pre">front.mu</span></code> - Front page listing all groups</p></li>
|
||||
<li><p><code class="docutils literal notranslate"><span class="pre">group.mu</span></code> - Group page listing repositories</p></li>
|
||||
<li><p><code class="docutils literal notranslate"><span class="pre">repo.mu</span></code> - Repository overview page</p></li>
|
||||
<li><p><code class="docutils literal notranslate"><span class="pre">tree.mu</span></code> - File browser pages</p></li>
|
||||
<li><p><code class="docutils literal notranslate"><span class="pre">blob.mu</span></code> - File content display</p></li>
|
||||
<li><p><code class="docutils literal notranslate"><span class="pre">commits.mu</span></code> - Commit history listing</p></li>
|
||||
<li><p><code class="docutils literal notranslate"><span class="pre">commit.mu</span></code> - Individual commit detail page</p></li>
|
||||
<li><p><code class="docutils literal notranslate"><span class="pre">refs.mu</span></code> - Branches and tags listing</p></li>
|
||||
<li><p><code class="docutils literal notranslate"><span class="pre">stats.mu</span></code> - Statistics page</p></li>
|
||||
</ul>
|
||||
<p>Templates can include the following variables:</p>
|
||||
<ul class="simple">
|
||||
<li><p><code class="docutils literal notranslate"><span class="pre">{PAGE_CONTENT}</span></code> - The main content of the page (required)</p></li>
|
||||
<li><p><code class="docutils literal notranslate"><span class="pre">{NODE_NAME}</span></code> - The configured node name</p></li>
|
||||
<li><p><code class="docutils literal notranslate"><span class="pre">{NAVIGATION}</span></code> - Breadcrumb navigation links</p></li>
|
||||
<li><p><code class="docutils literal notranslate"><span class="pre">{VERSION}</span></code> - The rngit version number</p></li>
|
||||
<li><p><code class="docutils literal notranslate"><span class="pre">{GEN_TIME}</span></code> - Page generation time</p></li>
|
||||
</ul>
|
||||
<p><strong>Dynamic Templates</strong></p>
|
||||
<p>Templates can be made executable to generate dynamic content. If a template file has the executable bit set, it will be executed and its stdout used as the template content.</p>
|
||||
<p><strong>Icon Sets</strong></p>
|
||||
<p>By default, the page server uses Nerd Font icons. If you prefer simpler icons or your terminal does not support Nerd Fonts, you can enable Unicode icons instead:</p>
|
||||
<div class="highlight-text notranslate"><div class="highlight"><pre><span></span>[pages]
|
||||
serve_nomadnet = yes
|
||||
unicode_icons = yes
|
||||
</pre></div>
|
||||
</div>
|
||||
<p><strong>Repository Statistics</strong></p>
|
||||
<p>When statistics recording is enabled (see the <code class="docutils literal notranslate"><span class="pre">record_stats</span></code> configuration option), the page server can display activity charts for each repository. The statistics page shows:</p>
|
||||
<ul class="simple">
|
||||
<li><p>Total and peak views, fetches and pushes</p></li>
|
||||
<li><p>Daily activity charts over a 90-day period</p></li>
|
||||
<li><p>Combined activity visualization</p></li>
|
||||
</ul>
|
||||
<p>To view statistics, a user must have the <code class="docutils literal notranslate"><span class="pre">s</span></code> (stats) permission for the repository. See the Access Configuration section for details on setting permissions.</p>
|
||||
<p><strong>Repository Thanks</strong></p>
|
||||
<p>The page server includes a “Thanks” feature that allows users to express appreciation for a repository. On each repository page, a “Thanks” link is displayed showing the current thanks count. Clicking this link registers a thank you for the repository.</p>
|
||||
<p><strong>Configuration Example</strong></p>
|
||||
<p>A complete page server configuration might look like this:</p>
|
||||
<div class="highlight-text notranslate"><div class="highlight"><pre><span></span>[rngit]
|
||||
node_name = My Git Server
|
||||
announce_interval = 360
|
||||
record_stats = yes
|
||||
|
||||
[repositories]
|
||||
public = /var/git/public
|
||||
internal = /var/git/internal
|
||||
|
||||
[access]
|
||||
public = r:all
|
||||
internal = rw:9710b86ba12c42d1d8f30f74fe509286
|
||||
|
||||
[pages]
|
||||
serve_nomadnet = yes
|
||||
unicode_icons = no
|
||||
</pre></div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="the-rnx-utility">
|
||||
<h3>The rnx Utility<a class="headerlink" href="#the-rnx-utility" title="Link to this heading">¶</a></h3>
|
||||
@@ -1707,7 +1818,7 @@ systemctl --user enable rnsd.service
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div><script src="_static/documentation_options.js?v=6efca38a"></script>
|
||||
</div><script src="_static/documentation_options.js?v=ca842793"></script>
|
||||
<script src="_static/doctools.js?v=9bcbadda"></script>
|
||||
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
|
||||
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
|
||||
|
||||
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
|
||||
<title>What is Reticulum? - Reticulum Network Stack 1.2.0 documentation</title>
|
||||
<title>What is Reticulum? - Reticulum Network Stack 1.2.1 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
|
||||
@@ -180,7 +180,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.0 documentation</div></a>
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.1 documentation</div></a>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="theme-toggle-container theme-toggle-header">
|
||||
@@ -204,7 +204,7 @@
|
||||
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
|
||||
</div>
|
||||
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.0 documentation</span>
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.1 documentation</span>
|
||||
|
||||
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
|
||||
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
|
||||
@@ -503,7 +503,7 @@ network, and vice versa.</p>
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div><script src="_static/documentation_options.js?v=6efca38a"></script>
|
||||
</div><script src="_static/documentation_options.js?v=ca842793"></script>
|
||||
<script src="_static/doctools.js?v=9bcbadda"></script>
|
||||
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
|
||||
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="prefetch" href="_static/rns_logo_512.png" as="image">
|
||||
|
||||
<!-- Generated with Sphinx 8.2.3 and Furo 2025.09.25.dev1 -->
|
||||
<title>Zen of Reticulum - Reticulum Network Stack 1.2.0 documentation</title>
|
||||
<title>Zen of Reticulum - Reticulum Network Stack 1.2.1 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=d111a655" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/styles/furo.css?v=580074bf" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/copybutton.css?v=76b2166b" />
|
||||
@@ -180,7 +180,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.0 documentation</div></a>
|
||||
<a href="index.html"><div class="brand">Reticulum Network Stack 1.2.1 documentation</div></a>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="theme-toggle-container theme-toggle-header">
|
||||
@@ -204,7 +204,7 @@
|
||||
<img class="sidebar-logo" src="_static/rns_logo_512.png" alt="Logo"/>
|
||||
</div>
|
||||
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.0 documentation</span>
|
||||
<span class="sidebar-brand-text">Reticulum Network Stack 1.2.1 documentation</span>
|
||||
|
||||
</a><form class="sidebar-search-container" method="get" action="search.html" role="search">
|
||||
<input class="sidebar-search" placeholder="Search" name="q" aria-label="Search">
|
||||
@@ -675,7 +675,7 @@ Imagine a messaging app. You write it once. It works on a laptop connected to fi
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div><script src="_static/documentation_options.js?v=6efca38a"></script>
|
||||
</div><script src="_static/documentation_options.js?v=ca842793"></script>
|
||||
<script src="_static/doctools.js?v=9bcbadda"></script>
|
||||
<script src="_static/sphinx_highlight.js?v=dc90522c"></script>
|
||||
<script src="_static/scripts/furo.js?v=46bd48cc"></script>
|
||||
|
||||
+150
-5
@@ -687,7 +687,7 @@ The ``rngit`` utility provides full Git repository hosting and interaction over
|
||||
|
||||
The system consists of two parts: The ``rngit`` node that hosts repositories, and the ``git-remote-rns`` helper that enables Git to communicate with rngit nodes. As soon as you have RNS installed on your system, you can transparently use Git with Reticulum-hosted repositories just like any other type of remote. Git over Reticulum uses URLs in the following format: ``rns://DESTINATION_HASH/group/repo``.
|
||||
|
||||
If you set a branch to track a Reticulum remote as the default upstream, you can simply use `git` as you normally would; all commands work transparently and as expected.
|
||||
If you set a branch to track a Reticulum remote as the default upstream, you can simply use ``git`` as you normally would; all commands work transparently and as expected.
|
||||
|
||||
.. warning::
|
||||
**The rngit program is a new addition to RNS!** This functionality was introduced in RNS 1.2.0. While great care has been taken to design a secure, but highly configurable and flexible permission system for allowing many users to interact with many different repositories on a single node, ``rngit`` has not been tested extensively in the wild! Be careful when hosting repositories, especially if they are public or semi-public.
|
||||
@@ -711,9 +711,11 @@ View your identity and destination hashes:
|
||||
|
||||
$ rngit --print-identity
|
||||
|
||||
Git Peer Identity : <959e10e5efc1bd9d97a4083babe51dea>
|
||||
Repository Node Identity : <153cb870b4665b8c1c348896292b0bad>
|
||||
Repositories Destination : <0d7334d411d00120cbad24edf355fdd2>
|
||||
Git Peer Identity : <959e10e5efc1bd9d97a4083babe51dea>
|
||||
Repository Node Identity : <153cb870b4665b8c1c348896292b0bad>
|
||||
Repositories Destination : <0d7334d411d00120cbad24edf355fdd2>
|
||||
|
||||
If the page server is enabled, the output will also include the Nomad Network destination hash.
|
||||
|
||||
You can run ``rngit`` in service mode with logging to file:
|
||||
|
||||
@@ -756,8 +758,11 @@ The ``rngit`` node configuration file is located at ``~/.rngit/config`` (or ``/e
|
||||
- Repository group paths defining where to find bare repositories
|
||||
- Access permissions for groups and individual repositories
|
||||
- Announce intervals for network visibility
|
||||
- Optional statistics recording for repository activity
|
||||
|
||||
Access permissions can be configured at the group level in the config file, or per-repository using ``.allowed`` files. Permissions use the format ``permission:target`` where permission is ``r`` (read), ``w`` (write), or ``rw`` (read/write), and target is ``all``, ``none``, or a specific identity hash.
|
||||
Access permissions can be configured at the group level in the config file, or per-repository using ``.allowed`` files. Permissions use the format ``permission:target`` where permission is ``r`` (read), ``w`` (write), ``rw`` (read/write), ``c`` (create) or ``s`` (stats) and target is ``all``, ``none``, or a specific identity hash.
|
||||
|
||||
The ``s`` (stats) permission allows viewing repository activity statistics, including views, fetches and pushes over time. To enable statistics recording, set ``record_stats = yes`` in the ``[rngit]`` section of the configuration file. You can also exclude specific identities from statistics by adding their hashes to ``stats_ignore_identities``.
|
||||
|
||||
Repository-specific ``.allowed`` files can be static text files or executable scripts that output permission rules to stdout. A ``group.allowed`` file in a repository group directory applies to all repositories within that group.
|
||||
|
||||
@@ -791,6 +796,146 @@ The ``git-remote-rns`` helper is automatically invoked by Git when interacting w
|
||||
|
||||
The client configuration file is located at ``~/.rngit/client_config`` and allows adjusting parameters such as the reference batch size for transfers.
|
||||
|
||||
**Serving Pages Over Nomad Network**
|
||||
|
||||
In addition to providing Git repository access via the Git remote helper protocol, ``rngit`` can also run a `Nomad Network <https://github.com/markqvist/nomadnet>`_ compatible page node. This allows users to browse repository information, view file contents, inspect commit history and access repository statistics through any Nomad Network client.
|
||||
|
||||
When enabled, the page node provides a complete interface to your repositories, with automatic Markdown to Micron conversion, syntax-highlighted code browsing, and detailed commit, diff and statistics views.
|
||||
|
||||
**Enabling the Git Page Node**
|
||||
|
||||
To enable the page node, add the following to your ``~/.rngit/config`` file:
|
||||
|
||||
.. code:: text
|
||||
|
||||
[pages]
|
||||
serve_nomadnet = yes
|
||||
|
||||
When the page node is enabled, ``rngit`` will listen on a Nomad Network node destination in addition to the Git repository destination. You can view the destination hash by running:
|
||||
|
||||
.. code:: text
|
||||
|
||||
$ rngit --print-identity
|
||||
|
||||
Git Peer Identity : <959e10e5efc1bd9d97a4083babe51dea>
|
||||
Repository Node Identity : <153cb870b4665b8c1c348896292b0bad>
|
||||
Repositories Destination : <0d7334d411d00120cbad24edf355fdd2>
|
||||
Nomad Network Destination : <50824b711717f97c2fb1166ceddd5ea9>
|
||||
|
||||
**Accessing Repository Pages**
|
||||
|
||||
Once the page server is running, you can access it from any Nomad Network client by connecting to the Nomad Network destination. The page node provides the following views:
|
||||
|
||||
- **Front Page** - Lists all repository groups accessible to your identity
|
||||
- **Group Page** - Shows all repositories within a group
|
||||
- **Repository Page** - Displays repository overview, description and README
|
||||
- **File Browser** - Browse directory trees and view file contents
|
||||
- **Commits View** - View commit history with pagination
|
||||
- **Commit Details** - Detailed commit information with file changes and diffs
|
||||
- **Refs View** - List branches and tags
|
||||
- **Statistics** - Activity charts showing views, fetches and pushes over time
|
||||
|
||||
All pages respect the same permission system used for Git access. If an identity does not have read access to a repository, they will not be able to view its pages.
|
||||
|
||||
**Syntax Highlighting**
|
||||
|
||||
If the ``pygments`` Python module is installed on your system, the page server will automatically apply syntax highlighting to code files. The highlighting supports a wide range of programming languages and uses a color theme optimized for terminal display.
|
||||
|
||||
To enable syntax highlighting, install pygments:
|
||||
|
||||
.. code:: text
|
||||
|
||||
pip install pygments
|
||||
|
||||
**Markdown & Micron Support**
|
||||
|
||||
README files and other Markdown documents are automatically converted to Micron markup for display in Nomad Network clients. You can also write your README files directly in Micron, in which case they will display and render as such in any Nomad Network client. The file browser also supports viewing both rendered and raw Markdown and Micron documents.
|
||||
|
||||
Code blocks in Markdown can include language hints for syntax highlighting:
|
||||
|
||||
.. code:: text
|
||||
|
||||
```python
|
||||
def hello_world():
|
||||
print("Hello, Reticulum!")
|
||||
```
|
||||
|
||||
**Customizing Templates**
|
||||
|
||||
The page server uses a template system that allows complete customization of the generated pages. Templates are stored in the ``~/.rngit/templates/`` directory as Micron files.
|
||||
|
||||
The following template files are supported:
|
||||
|
||||
- ``base.mu`` - Base template wrapping all pages (must include ``{PAGE_CONTENT}``)
|
||||
- ``front.mu`` - Front page listing all groups
|
||||
- ``group.mu`` - Group page listing repositories
|
||||
- ``repo.mu`` - Repository overview page
|
||||
- ``tree.mu`` - File browser pages
|
||||
- ``blob.mu`` - File content display
|
||||
- ``commits.mu`` - Commit history listing
|
||||
- ``commit.mu`` - Individual commit detail page
|
||||
- ``refs.mu`` - Branches and tags listing
|
||||
- ``stats.mu`` - Statistics page
|
||||
|
||||
Templates can include the following variables:
|
||||
|
||||
- ``{PAGE_CONTENT}`` - The main content of the page (required)
|
||||
- ``{NODE_NAME}`` - The configured node name
|
||||
- ``{NAVIGATION}`` - Breadcrumb navigation links
|
||||
- ``{VERSION}`` - The rngit version number
|
||||
- ``{GEN_TIME}`` - Page generation time
|
||||
|
||||
**Dynamic Templates**
|
||||
|
||||
Templates can be made executable to generate dynamic content. If a template file has the executable bit set, it will be executed and its stdout used as the template content.
|
||||
|
||||
**Icon Sets**
|
||||
|
||||
By default, the page server uses Nerd Font icons. If you prefer simpler icons or your terminal does not support Nerd Fonts, you can enable Unicode icons instead:
|
||||
|
||||
.. code:: text
|
||||
|
||||
[pages]
|
||||
serve_nomadnet = yes
|
||||
unicode_icons = yes
|
||||
|
||||
**Repository Statistics**
|
||||
|
||||
When statistics recording is enabled (see the ``record_stats`` configuration option), the page server can display activity charts for each repository. The statistics page shows:
|
||||
|
||||
- Total and peak views, fetches and pushes
|
||||
- Daily activity charts over a 90-day period
|
||||
- Combined activity visualization
|
||||
|
||||
To view statistics, a user must have the ``s`` (stats) permission for the repository. See the Access Configuration section for details on setting permissions.
|
||||
|
||||
**Repository Thanks**
|
||||
|
||||
The page server includes a "Thanks" feature that allows users to express appreciation for a repository. On each repository page, a "Thanks" link is displayed showing the current thanks count. Clicking this link registers a thank you for the repository.
|
||||
|
||||
**Configuration Example**
|
||||
|
||||
A complete page server configuration might look like this:
|
||||
|
||||
.. code:: text
|
||||
|
||||
[rngit]
|
||||
node_name = My Git Server
|
||||
announce_interval = 360
|
||||
record_stats = yes
|
||||
|
||||
[repositories]
|
||||
public = /var/git/public
|
||||
internal = /var/git/internal
|
||||
|
||||
[access]
|
||||
public = r:all
|
||||
internal = rw:9710b86ba12c42d1d8f30f74fe509286
|
||||
|
||||
[pages]
|
||||
serve_nomadnet = yes
|
||||
unicode_icons = no
|
||||
|
||||
|
||||
The rnx Utility
|
||||
================
|
||||
|
||||
Reference in New Issue
Block a user