From c7ae0f2c4ec82ac3386b3e6cd2421f2f355546c9 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 01:57:00 +0000 Subject: [PATCH] feat: Add SovereignRelay Meshtastic-XMPP bridge This commit introduces a MVP for the sovereign computing hackathon. It provides a Python-based bridge between a local Meshtastic node and an internet-connected XMPP Multi-User Chat room. Features: - `bridge.py`: Uses `meshtastic` and `slixmpp` to bridge messages bidirectionally. - Properly handles asynchronous/synchronous impedance mismatch between XMPP and serial interfaces. - Avoids infinite loop echo chambers. - `flake.nix`: Packages the Python script and its dependencies. - Exposes a NixOS module for configuring the bridge as a persistent systemd service. - Requires `--password-file` to prevent exposing XMPP passwords in process listings. - `README.md`: Explains architecture, usage, and how to configure the NixOS module. Co-authored-by: jamessucla <2191476+jamessucla@users.noreply.github.com> --- README.md | 68 ++++++++++++++++++++++++++++++++ bridge.py | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 95 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 276 insertions(+) create mode 100644 README.md create mode 100644 bridge.py create mode 100644 flake.nix diff --git a/README.md b/README.md new file mode 100644 index 0000000..386a665 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# SovereignRelay + +SovereignRelay is an off-grid resilient communication bridge built with NixOS. It connects local Meshtastic LoRa mesh networks to the federated internet via XMPP. + +If the internet goes down, locals can communicate over the Meshtastic LoRa mesh. When the internet is up, a NixOS bridge flawlessly forwards local mesh messages to a federated XMPP Multi-User Chat (MUC) and vice versa, keeping the off-grid community connected to the broader world. + +## Architecture + +* **The Edge:** Local users connected to Meshtastic LoRa radios (e.g., LILYGO T-Beams or RAK WisBlocks). +* **The Bridge Hardware:** A machine (like a laptop or Raspberry Pi) running NixOS. A Meshtastic radio connects to it via USB (Serial). +* **The Bridge Software:** A Python daemon that actively listens to the Meshtastic serial stream and an XMPP connection. +* **The Federated Layer:** XMPP server facilitating connections globally. + +## Prerequisites +- A local NixOS installation with flakes enabled. +- A Meshtastic device connected via USB to the NixOS machine. +- An XMPP account that can join MUCs. + +## Usage + +### Using the Nix Flake directly + +You can run the python bridge straight from the flake: + +```bash +nix run . -- -j "your_jid@xmpp.org" -p "your_password" -r "your_room@conference.xmpp.org" -n "meshbridge" +``` + +### Developing + +You can drop into a Nix shell with all the required python dependencies: + +```bash +nix develop +``` + +### NixOS Module (Systemd Service) + +SovereignRelay provides a NixOS module to seamlessly integrate the bridge as a declarative `systemd` service that will persist, automatically start on boot, and autorestart on failure. + +Include the flake in your `flake.nix` inputs: + +```nix +{ + inputs.sovereign-relay.url = "github:yourusername/sovereign-relay"; + # ... +} +``` + +Then in your NixOS configuration (`configuration.nix` or similar): + +```nix +{ + imports = [ + inputs.sovereign-relay.nixosModules.default + ]; + + services.sovereign-bridge = { + enable = true; + jid = "your_jid@xmpp.org"; + passwordFile = "/run/secrets/xmpp_password"; + room = "your_room@conference.xmpp.org"; + nick = "meshbridge"; + }; +} +``` + +The bridge daemon requires the `dialout` group to read the serial interface from the Meshtastic USB connection, which is handled automatically by the module's configuration. \ No newline at end of file diff --git a/bridge.py b/bridge.py new file mode 100644 index 0000000..960bd9b --- /dev/null +++ b/bridge.py @@ -0,0 +1,113 @@ +import sys +import time +import logging +import asyncio +from pubsub import pub +import meshtastic +import meshtastic.serial_interface +import slixmpp + +logging.basicConfig(level=logging.INFO, format='%(levelname)-8s %(message)s') + +class SovereignBridge(slixmpp.ClientXMPP): + def __init__(self, jid, password, room, nick): + super().__init__(jid, password) + self.room = room + self.nick = nick + self.meshtastic_interface = None + + # Plugins for MUC + self.register_plugin('xep_0030') # Service Discovery + self.register_plugin('xep_0045') # Multi-User Chat + self.register_plugin('xep_0199') # XMPP Ping + + self.add_event_handler("session_start", self.start) + self.add_event_handler("groupchat_message", self.muc_message) + + # Setup Meshtastic pubsub listeners + pub.subscribe(self.on_meshtastic_receive, "meshtastic.receive.text") + + async def start(self, event): + self.send_presence() + await self.get_roster() + self.plugin['xep_0045'].join_muc(self.room, self.nick) + logging.info(f"Joined XMPP MUC: {self.room} as {self.nick}") + + def muc_message(self, msg): + # Ignore our own messages to avoid loops + if msg['mucnick'] == self.nick: + return + + body = msg['body'] + sender = msg['mucnick'] + logging.info(f"[XMPP -> Meshtastic] {sender}: {body}") + + if self.meshtastic_interface: + # Run the synchronous meshtastic call in a thread pool to avoid blocking asyncio + asyncio.ensure_future( + asyncio.to_thread(self._send_to_meshtastic, f"{sender}: {body}") + ) + + def _send_to_meshtastic(self, msg_text): + try: + self.meshtastic_interface.sendText(msg_text) + except Exception as e: + logging.error(f"Error sending to Meshtastic: {e}") + + def on_meshtastic_receive(self, packet, interface): + try: + if 'decoded' in packet and 'text' in packet['decoded']: + text = packet['decoded']['text'] + sender_id = packet.get('fromId', 'Unknown') + + logging.info(f"[Meshtastic -> XMPP] {sender_id}: {text}") + + # Forward to XMPP MUC safely from the meshtastic pubsub thread + self.loop.call_soon_threadsafe(self._send_xmpp_message, sender_id, text) + except Exception as e: + logging.error(f"Error processing Meshtastic packet: {e}") + + def _send_xmpp_message(self, sender_id, text): + try: + msg = self.make_message(mto=self.room, mbody=f"[{sender_id}] {text}", mtype='groupchat') + msg.send() + except Exception as e: + logging.error(f"Error sending XMPP message: {e}") + + def connect_meshtastic(self): + logging.info("Connecting to Meshtastic node...") + try: + # Connect to the first available serial port + self.meshtastic_interface = meshtastic.serial_interface.SerialInterface() + logging.info("Connected to Meshtastic node.") + except Exception as e: + logging.error(f"Could not connect to Meshtastic node: {e}") + sys.exit(1) + +def main(): + import argparse + parser = argparse.ArgumentParser(description="Meshtastic <-> XMPP Bridge") + parser.add_argument("-j", "--jid", required=True, help="JID to use") + parser.add_argument("-P", "--password-file", required=False, help="File containing password to use") + parser.add_argument("-p", "--password", required=False, help="Password to use") + parser.add_argument("-r", "--room", required=True, help="MUC room to join") + parser.add_argument("-n", "--nick", required=True, help="MUC nickname") + args = parser.parse_args() + + password = args.password + if args.password_file: + with open(args.password_file, 'r') as f: + password = f.read().strip() + + if not password: + parser.error("Either --password or --password-file is required") + + xmpp = SovereignBridge(args.jid, password, args.room, args.nick) + xmpp.connect_meshtastic() + + logging.info("Connecting to XMPP server...") + xmpp.connect() + xmpp.process(forever=True) + +if __name__ == '__main__': + main() diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..8ab77f4 --- /dev/null +++ b/flake.nix @@ -0,0 +1,95 @@ +{ + description = "SovereignRelay - Meshtastic to XMPP Bridge"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + + # Define the Python environment with required packages + pythonEnv = pkgs.python3.withPackages (ps: with ps; [ + meshtastic + slixmpp + ]); + + # Package the bridge script + sovereign-bridge = pkgs.writeScriptBin "sovereign-bridge" '' + #!${pythonEnv}/bin/python + ${builtins.readFile ./bridge.py} + ''; + + in + { + packages.default = sovereign-bridge; + + devShells.default = pkgs.mkShell { + buildInputs = [ + pythonEnv + sovereign-bridge + ]; + }; + } + ) // { + nixosModules.default = { config, lib, pkgs, ... }: + with lib; + let + cfg = config.services.sovereign-bridge; + in { + options.services.sovereign-bridge = { + enable = mkEnableOption "SovereignRelay Bridge"; + + jid = mkOption { + type = types.str; + description = "XMPP JID for the bridge bot"; + }; + + passwordFile = mkOption { + type = types.path; + description = "Path to file containing XMPP password"; + }; + + room = mkOption { + type = types.str; + description = "XMPP MUC room to bridge"; + }; + + nick = mkOption { + type = types.str; + default = "meshbridge"; + description = "Nickname for the bridge bot in the MUC"; + }; + }; + + config = mkIf cfg.enable { + systemd.services.sovereign-bridge = { + description = "SovereignRelay Meshtastic to XMPP Bridge"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + ExecStart = let + script = pkgs.writeShellScript "sovereign-bridge-start" '' + # Run the bridge + ${self.packages.${pkgs.system}.default}/bin/sovereign-bridge \ + -j "${cfg.jid}" \ + -P "${cfg.passwordFile}" \ + -r "${cfg.room}" \ + -n "${cfg.nick}" + ''; + in "${script}"; + Restart = "always"; + RestartSec = "10"; + # Required to access serial ports for Meshtastic + SupplementaryGroups = [ "dialout" ]; + DynamicUser = true; + }; + }; + }; + }; + }; +} \ No newline at end of file