Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit 2d14e064c7
1589 changed files with 336062 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.venv/
__pycache__/
*.pyc
dist/
build/
*.spec
# never commit a real identity key or RNS state
*.key
node_key
.archy-reticulum/
+66
View File
@@ -0,0 +1,66 @@
# reticulum-daemon
Host-supervised **Reticulum (RNS) + LXMF** bridge for Archipelago's Mesh tab. This is
the Python side of the [Reticulum transport plan](../../.claude/plans/enchanted-strolling-rocket.md):
archipelago spawns one of these per active Reticulum (RNode) radio, it owns the serial
port, and the Rust mesh subsystem drives it over a Unix-socket JSON-RPC.
Why a daemon (not the Rust `reticulum-rs` crate): the canonical Python `rns`/`lxmf`
guarantees interop with Sideband / NomadNet / MeshChat, and lets us derive the RNS
identity from the existing Archy key (proven in `spike_identity.py`).
## Layout
- `archy_rns_identity.py` — derive a **deterministic** RNS `Identity` from the 32-byte
Archy Ed25519 seed (`identity_dir/node_key`) via domain-separated HKDF. The node's
LXMF destination hash is a stable function of the Archy identity.
- `spike_identity.py`**Phase-0 gate #1** (no radio): proves that determinism.
- `reticulum_daemon.py` — the daemon: RNS bring-up, LXMF router, announce handler, and
the Unix-socket RPC. See its module docstring for the wire protocol.
- `requirements.txt` — pinned `rns==1.3.5`, `lxmf==1.0.1` (validated on Python 3.13).
## Dev setup
```sh
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
```
## Run the spike / smoke tests (no hardware)
```sh
.venv/bin/python spike_identity.py # gate #1: identity determinism
.venv/bin/python reticulum_daemon.py --check \
--identity-key /path/to/node_key # print this node's dest hash
.venv/bin/python reticulum_daemon.py --selftest \
--identity-key /path/to/node_key # bring up RNS+LXMF, no radio
```
## Run against a real RNode (Phase-0 hardware gate, on .116 / .228)
```sh
.venv/bin/python reticulum_daemon.py \
--identity-key /var/lib/archipelago/identity/node_key \
--serial-port /dev/reticulum-radio \
--socket /run/archy/reticulum.sock \
--display-name "archy-228"
```
Then verify a two-node LXMF DM over LoRa and interop with a stock Sideband/MeshChat
client (Phase-0 gates #2 and #3).
## Packaging (Phase 1)
Ship as a **PyInstaller single binary** in the OTA next to `/usr/local/bin/archipelago`
(no provision-time `pip install`). archipelago supervises it: start on RNode detect,
kill on unplug/disable. The RPC socket and RNS config dir are archipelago-owned, 0600.
```sh
./build.sh # → dist/archy-reticulum-daemon (~16M, fully standalone)
```
`-d noarchive` is required, not optional — see the comment in `build.sh`: RNS computes
`RNS.Interfaces.__all__` via a `glob()` against its own `__file__` directory at import
time, which only works when PyInstaller keeps modules as loose files instead of zipping
them into the binary.
## Status
Phase-0 gate #1 (identity determinism) **passes**, verified in both the dev venv and the
packaged binary (same dest hash). The signed-identity announce (`ARCHY:2:{ed}:{x25519}` in
`_announce_app_data`, via `--archy-ed-pubkey-hex`/`--archy-x25519-pubkey-hex`) is wired and
the Rust side (`reticulum.rs`) already passes the node's real keys through. Packaging is
done and verified standalone. What's left is entirely hardware-dependent: the live LoRa
message path (Phase-0 gates #2/#3) needs a real RNode-flashed board.
+80
View File
@@ -0,0 +1,80 @@
"""Derive a stable Reticulum (RNS) Identity from the Archipelago node identity.
Archy's root identity is a single 32-byte Ed25519 seed (``identity_dir/node_key``,
0600 — see core/archipelago/src/identity.rs). A Reticulum ``Identity`` is a *pair*
of keypairs: an X25519 (encryption) key and an Ed25519 (signing) key, whose
concatenated 64-byte private blob is ``x25519_priv(32) || ed25519_priv(32)``.
We derive both halves deterministically from the Archy seed with domain-separated
HKDF-SHA256. Properties this gives us:
* **Reproducible** — the same Archy node always produces the same RNS destination
hash, so a contact's Reticulum address is stable across reboots / reinstalls and
peers can bind it to the existing Archy contact (no manual re-pairing).
* **Domain-separated** — we never reuse the raw Archy signing key inside RNS; each
derived key has its own HKDF ``info`` label. Reusing one private key across two
cryptographic schemes is a footgun we deliberately avoid.
Binding to the Archy DID/Npub is NOT done by key reuse — it is carried in the signed
announce app-data (see ``build_announce_app_data``), which peers verify against the
Archy ed25519 identity and then bind onto the contact's stable ``arch_pubkey_hex``.
"""
from __future__ import annotations
import hashlib
import hmac
# HKDF info labels — changing these changes every node's RNS address, so they are
# part of the wire contract. Do not edit without a migration.
_INFO_X25519 = b"archipelago/reticulum/x25519/v1"
_INFO_ED25519 = b"archipelago/reticulum/ed25519/v1"
_HKDF_SALT = b"archipelago-reticulum-identity-v1"
def _hkdf_sha256(ikm: bytes, info: bytes, length: int = 32) -> bytes:
"""RFC 5869 HKDF-SHA256 (extract + expand) for one output block (length <= 32)."""
if length > 32:
raise ValueError("this helper only emits up to one SHA-256 block")
prk = hmac.new(_HKDF_SALT, ikm, hashlib.sha256).digest() # extract
okm = hmac.new(prk, info + b"\x01", hashlib.sha256).digest() # expand (T(1))
return okm[:length]
def rns_private_blob(archy_ed25519_seed: bytes) -> bytes:
"""Return the 64-byte RNS private blob (x25519_priv || ed25519_priv).
``archy_ed25519_seed`` is the raw 32 bytes of ``identity_dir/node_key``.
"""
if len(archy_ed25519_seed) != 32:
raise ValueError(f"expected a 32-byte Archy ed25519 seed, got {len(archy_ed25519_seed)}")
x25519_priv = _hkdf_sha256(archy_ed25519_seed, _INFO_X25519, 32)
ed25519_priv = _hkdf_sha256(archy_ed25519_seed, _INFO_ED25519, 32)
return x25519_priv + ed25519_priv
def load_identity(archy_ed25519_seed: bytes):
"""Build an ``RNS.Identity`` deterministically from the Archy seed.
Imported lazily so this module (and the determinism unit test) can be reasoned
about without RNS installed; the daemon imports it after the venv is present.
"""
import RNS # noqa: PLC0415 — lazy by design
blob = rns_private_blob(archy_ed25519_seed)
identity = RNS.Identity(create_keys=False)
identity.load_private_key(blob)
return identity
def lxmf_destination_hash(archy_ed25519_seed: bytes) -> bytes:
"""The 16-byte LXMF *delivery* destination hash for this node's identity.
Uses the static ``Destination.hash`` so we can derive the address without a
running Reticulum/Transport instance (the daemon computes this at startup,
before bringing interfaces up).
"""
import RNS # noqa: PLC0415
identity = load_identity(archy_ed25519_seed)
return RNS.Destination.hash(identity, "lxmf", "delivery")
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Build the PyInstaller single-binaries for the OTA (plan Phase 1 packaging).
# Outputs: dist/archy-reticulum-daemon, dist/archy-rnodeconf — drop both next
# to /usr/local/bin/archipelago.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
if [ ! -d .venv ]; then
python3 -m venv .venv
fi
.venv/bin/pip install -q -r requirements.txt -r requirements-build.txt
rm -rf build dist archy-reticulum-daemon.spec archy-rnodeconf.spec
# --collect-submodules: RNS/LXMF load most of their own internals dynamically
# (interface drivers, transport backends), which PyInstaller's static import
# analysis can't see from a plain `import RNS`.
#
# -d noarchive is NOT optional: RNS.Interfaces/__init__.py builds its
# `__all__` by glob-ing *.py/*.pyc next to its own `__file__` at import time
# (`from RNS.Interfaces import *` in Reticulum.py relies on that). PyInstaller
# normally zips pure-Python modules into an in-binary PYZ archive, so
# `__file__` doesn't point at a real directory and the glob comes back empty
# -> `NameError: name 'Interface' is not defined` at RNS.Reticulum() bring-up.
# noarchive keeps modules as loose .pyc files on disk so the glob still works.
.venv/bin/pyinstaller --onefile --name archy-reticulum-daemon --clean --noconfirm \
--collect-submodules RNS \
--collect-submodules LXMF \
--collect-data RNS \
-d noarchive \
reticulum_daemon.py
echo "Built dist/archy-reticulum-daemon ($(du -h dist/archy-reticulum-daemon | cut -f1))"
# archy-rnodeconf: RNS's own official RNode config/diagnostic tool
# (RNS.Utilities.rnodeconf — reads/sets frequency, bandwidth, spreading
# factor, coding rate, TX power on an attached RNode; also verifies firmware
# signatures and can flash/bootstrap a board). Shipped alongside the daemon
# so every node can inspect and reconfigure its own radio without needing a
# full RNS/Python dev environment set up by hand — see the checkpoint in
# docs/RETICULUM-TRANSPORT-PROGRESS.md for the incident (two nodes silently
# running at different spreading factors, invisible without this tool) that
# motivated shipping it as a first-class OS tool rather than an ad hoc script.
RNODECONF_SRC="$(find .venv/lib -maxdepth 5 -path '*/site-packages/RNS/Utilities/rnodeconf.py' -print -quit)"
if [ -n "$RNODECONF_SRC" ] && [ -f "$RNODECONF_SRC" ]; then
# --runtime-hook: rnodeconf's own graceful_exit() calls the bare
# exit()/quit() builtins, which only exist in interactive Python (site.py
# injects them) — a frozen app hits NameError right as it tries to quit
# cleanly, after all the real work already succeeded. See
# pyi_rthook_exit_builtins.py.
.venv/bin/pyinstaller --onefile --name archy-rnodeconf --clean --noconfirm \
--collect-submodules RNS \
--collect-data RNS \
--runtime-hook pyi_rthook_exit_builtins.py \
-d noarchive \
"$RNODECONF_SRC"
echo "Built dist/archy-rnodeconf ($(du -h dist/archy-rnodeconf | cut -f1))"
else
echo "WARNING: rnodeconf.py not found at $RNODECONF_SRC (RNS version mismatch?) — skipping archy-rnodeconf build" >&2
fi
@@ -0,0 +1,18 @@
# PyInstaller runtime hook — see build.sh.
#
# `exit()`/`quit()` aren't part of the language; they're `site.Quitter`
# instances the interactive interpreter injects into builtins at startup
# (site.py). A frozen PyInstaller app never runs that interactive-mode
# init, so any bundled script that calls bare `exit()` (RNS's own
# rnodeconf.py does, in its graceful_exit() cleanup path) hits
# `NameError: name 'exit' is not defined` right as it tries to quit
# cleanly — the real work above it already completed, but the process
# still exits 1, which is a foot-gun for anything scripting off the exit
# code. Pre-define both as sys.exit so that path is a no-op crash-wise.
import builtins
import sys
if not hasattr(builtins, "exit"):
builtins.exit = sys.exit
if not hasattr(builtins, "quit"):
builtins.quit = sys.exit
+2
View File
@@ -0,0 +1,2 @@
# Build-only dependency, not shipped at runtime — see build.sh.
pyinstaller==6.21.0
+4
View File
@@ -0,0 +1,4 @@
# Canonical Reticulum stack — pinned for reproducible, hash-verified bundles.
# Validated in the Phase-0 spike: RNS 1.3.5 + LXMF 1.0.1 on Python 3.13.
rns==1.3.5
lxmf==1.0.1
+625
View File
@@ -0,0 +1,625 @@
#!/usr/bin/env python3
"""Archipelago Reticulum daemon — host-supervised RNS + LXMF bridge.
archipelago spawns and supervises one of these per active Reticulum (RNode) radio.
It owns the serial port, runs the Reticulum stack + an LXMF router whose identity is
**derived deterministically from the Archy node key**, and exposes a tiny
line-delimited JSON-RPC over a Unix domain socket (0600) for the Rust side to drive.
Security posture (see the plan's "most secure way" section):
* Runs as the same rootless archipelago user; no root, no network control plane.
* RPC is a Unix socket only; the identity key never leaves the host and is never
logged. The daemon executes ONLY the fixed verb set below.
RPC (one JSON object per line, both directions):
in : {"cmd":"send","dest_hash":"<hex16>","content":"","title":"","method":"direct|opportunistic"}
{"cmd":"announce"}
{"cmd":"status"}
{"cmd":"send_resource","id":"<correlation>","dest_hash":"<hex16>","data_b64":""}
{"cmd":"shutdown"}
out: {"event":"ready","dest_hash":"<hex16>","display_name":""}
{"event":"recv","source_hash":"<hex16>","content":"","title":"","fields":{…},"app_data":"<hex>","rssi":n,"snr":n,"stamp":t}
{"event":"announce","dest_hash":"<hex16>","app_data":"<hex>"}
{"event":"delivered","dest_hash":"<hex16>","state":"delivered|failed","id":"<hex>"}
{"event":"status","connected":bool,"dest_hash":"<hex16>","interfaces":[…]}
{"event":"resource_progress","id":"<correlation>","transferred":n,"total":n}
{"event":"resource_sent","id":"<correlation>"}
{"event":"resource_failed","id":"<correlation>","reason":""}
{"event":"resource_recv","source_hash":"<hex16>","data_b64":""}
``send_resource`` is for large (>~2.3KB) binary payloads that don't fit the small
LXMF-message path — it uses RNS's native Resource transfer protocol over a `RNS.Link`
to the peer's *resource* destination (a separate aspect from the LXMF delivery
destination, so it doesn't disturb normal messaging). Built for sending compressed
photos/files/voice-messages directly over LoRa instead of always falling back to Tor
past the small-message size cap. ``resource_recv``'s `data_b64` is the same
CBOR-encoded payload format already used for the small-inline LoRa path, so the Rust
side decodes it identically regardless of which path delivered it.
This is the Phase-1 skeleton: the identity/LXMF wiring and RPC loop are real and
exercised by ``--check`` / ``--selftest`` (no radio). The live LoRa message path is
validated in the Phase-0 hardware gate on .116/.228.
"""
from __future__ import annotations
import argparse
import asyncio
import base64
import json
import os
import signal
import sys
from pathlib import Path
from archy_rns_identity import lxmf_destination_hash, load_identity
# Lazy heavy imports (RNS/LXMF) happen in run(); --check stays import-light.
# ─────────────────────────── RNS config generation ───────────────────────────
def _require_loopback(host: str) -> None:
"""Bind-scope guard: TCP server mode is dev/verification only for now. WAN/LAN
exposure is a separate future decision needing its own security review — archy
is Tor-first for inter-node traffic (see README/CLAUDE.md), and a plain-TCP
Reticulum listener bound beyond loopback would bypass that entirely. Defense in
depth: the Rust side enforces the same rule (reticulum.rs::is_loopback_host),
but this daemon can also be invoked directly, so it re-checks here too."""
if host not in ("127.0.0.1", "::1", "localhost"):
raise SystemExit(
f"--tcp-listen host must be loopback-only (got {host!r}); "
"WAN/LAN bind is out of scope for this daemon build"
)
def _write_rns_config(
configdir: Path,
*,
serial_port: str | None,
lora: dict,
no_radio: bool,
tcp_listen: str | None = None,
tcp_connect: list[str] | None = None,
) -> None:
"""Materialise an RNS config file. RNode interface for real radios; plain-TCP
server/client interface(s) for radio-less dev/verification (e.g. Aurora
interop testing); a loopback-only disabled config for --selftest so the stack
comes up with zero interfaces at all."""
configdir.mkdir(parents=True, exist_ok=True)
cfg = configdir / "config"
if no_radio:
interfaces = (
" [[Default Interface]]\n"
" type = AutoInterface\n"
" enabled = no\n"
)
elif serial_port:
interfaces = (
" [[RNode LoRa]]\n"
" type = RNodeInterface\n"
" enabled = yes\n"
f" port = {serial_port}\n"
f" frequency = {lora['frequency']}\n"
f" bandwidth = {lora['bandwidth']}\n"
f" txpower = {lora['txpower']}\n"
f" spreadingfactor = {lora['spreadingfactor']}\n"
f" codingrate = {lora['codingrate']}\n"
)
elif tcp_listen or tcp_connect:
parts = []
if tcp_listen:
host, _, port = tcp_listen.rpartition(":")
_require_loopback(host)
parts.append(
" [[Reticulum TCP Server]]\n"
" type = TCPServerInterface\n"
" enabled = yes\n"
f" listen_ip = {host}\n"
f" listen_port = {port}\n"
)
for i, target in enumerate(tcp_connect or []):
host, _, port = target.rpartition(":")
parts.append(
f" [[Reticulum TCP Client {i}]]\n"
" type = TCPClientInterface\n"
" enabled = yes\n"
f" target_host = {host}\n"
f" target_port = {port}\n"
)
interfaces = "\n".join(parts)
else:
raise SystemExit(
"no interface configured: need --serial-port, --tcp-listen/--tcp-connect, "
"or --no-radio"
)
cfg.write_text(
"[reticulum]\n"
" enable_transport = no\n"
" share_instance = no\n"
" panic_on_interface_error = no\n\n"
"[interfaces]\n" + interfaces
)
os.chmod(cfg, 0o600)
# ─────────────────────────────── the daemon ──────────────────────────────────
class ReticulumDaemon:
def __init__(self, args):
self.args = args
self.seed = self._read_seed(Path(args.identity_key))
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self.clients: set[asyncio.StreamWriter] = set()
self.reticulum = None
self.router = None
self.delivery_destination = None
self.identity = None
self.dest_hash_hex = lxmf_destination_hash(self.seed).hex()
# Resource transfer (large binary payloads over a dedicated Link, separate
# from LXMF delivery — see module docstring). Keyed by the peer's *resource*
# destination hash (bytes), not their LXMF delivery hash.
self.resource_destination = None
self.links: dict[bytes, "RNS.Link"] = {}
self.pending_resource_sends: dict[bytes, list[tuple[bytes, str]]] = {}
@staticmethod
def _read_seed(path: Path) -> bytes:
seed = path.read_bytes()
if len(seed) != 32:
raise SystemExit(f"identity key {path} must be 32 bytes, got {len(seed)}")
return seed
# ---- RNS / LXMF bring-up ----
def bring_up(self):
import RNS
import LXMF
configdir = Path(self.args.rns_config)
_write_rns_config(
configdir,
serial_port=self.args.serial_port,
lora={
"frequency": self.args.frequency,
"bandwidth": self.args.bandwidth,
"txpower": self.args.txpower,
"spreadingfactor": self.args.spreadingfactor,
"codingrate": self.args.codingrate,
},
no_radio=self.args.no_radio,
tcp_listen=self.args.tcp_listen,
tcp_connect=self.args.tcp_connect,
)
self.reticulum = RNS.Reticulum(configdir=str(configdir))
self.identity = load_identity(self.seed)
storagepath = str(configdir / "lxmf")
Path(storagepath).mkdir(parents=True, exist_ok=True)
self.router = LXMF.LXMRouter(identity=self.identity, storagepath=storagepath)
self.delivery_destination = self.router.register_delivery_identity(
self.identity, display_name=self.args.display_name
)
self.router.register_delivery_callback(self._on_lxmf_delivery)
# Hear other LXMF nodes' announces so we can surface peers + bind contacts.
RNS.Transport.register_announce_handler(_AnnounceHandler(self))
assert self.delivery_destination.hash.hex() == self.dest_hash_hex, (
"derived dest hash diverged from LXMF's — aspect/identity mismatch"
)
# Separate destination/aspect for inbound Resource transfers (large
# binary payloads), so it never collides with LXMF delivery traffic.
# Every peer running this same daemon code can derive our resource
# destination hash from our (already-announced) identity via
# RNS.Destination.hash(identity, "archy", "resource") — see
# _resource_dest_hash_for, used on the sending side.
self.resource_destination = RNS.Destination(
self.identity, RNS.Destination.IN, RNS.Destination.SINGLE,
"archy", "resource",
)
self.resource_destination.set_link_established_callback(
self._on_resource_link_established
)
def announce(self):
if self.delivery_destination is not None:
self.delivery_destination.announce(app_data=self._announce_app_data())
def _announce_app_data(self) -> bytes:
"""Carry the Archy identity so peers bind this RNS destination onto the
existing contact, the same way a meshcore/Meshtastic identity advert does.
Reuses the exact ``ARCHY:2:{ed25519_hex}:{x25519_hex}`` wire format the
Rust side already parses (``protocol::parse_identity_broadcast``) and
binds via ``handle_identity_received``/``bind_federation_twins`` — so a
Reticulum-carried identity merges into the SAME conversation as the
meshcore/Meshtastic/federation twins of the same Archy node, satisfying
cross-protocol DM convergence. The keys are the node's real Archipelago
ed25519/x25519 pubkeys (passed in by the Rust side, which already has
them) — NOT this daemon's internally-HKDF-derived RNS keys, which exist
only to make the RNS destination hash deterministic and are never
themselves treated as an Archy identity.
Falls back to a plain display-name string (undetected as an identity
blob — no `ARCHY:2:` prefix) if the Archy pubkeys weren't supplied, e.g.
a dev/selftest run with no `--archy-ed-pubkey-hex`.
"""
if self.args.archy_ed_pubkey_hex and self.args.archy_x25519_pubkey_hex:
return (
f"ARCHY:2:{self.args.archy_ed_pubkey_hex}:"
f"{self.args.archy_x25519_pubkey_hex}"
).encode("ascii")
return (self.args.display_name or "").encode("utf-8")
# ---- RNS-thread callbacks → asyncio ----
def _on_lxmf_delivery(self, message):
import LXMF
try:
app_data = b""
src = message.source_hash.hex() if message.source_hash else ""
event = {
"event": "recv",
"source_hash": src,
"content": message.content_as_string() if hasattr(message, "content_as_string")
else (message.content.decode("utf-8", "replace") if message.content else ""),
"title": message.title_as_string() if hasattr(message, "title_as_string") else "",
"app_data": app_data.hex(),
"stamp": getattr(message, "timestamp", None),
}
# Native LXMF attachment fields (Sideband/NomadNet/stock clients use
# these, NOT our own typed-envelope wire format) — a stock client's
# photo/voice-memo/file arrives here, not in `content`, which is why
# it was previously dropped silently (content was just blank/space).
# See LXMF field format confirmed against Sideband's own source
# (sbapp/sideband/core.py): FIELD_IMAGE = [format_str, bytes],
# FIELD_AUDIO = [mode_byte, bytes], FIELD_FILE_ATTACHMENTS =
# [[filename, bytes], ...].
fields = getattr(message, "fields", None) or {}
if LXMF.FIELD_IMAGE in fields:
fmt, img_bytes = fields[LXMF.FIELD_IMAGE]
event["image_format"] = str(fmt)
event["image_b64"] = base64.b64encode(bytes(img_bytes)).decode("ascii")
if LXMF.FIELD_FILE_ATTACHMENTS in fields:
attachments = fields[LXMF.FIELD_FILE_ATTACHMENTS]
if attachments:
filename, file_bytes = attachments[0]
event["attachment_filename"] = str(filename)
event["attachment_b64"] = base64.b64encode(bytes(file_bytes)).decode("ascii")
self._emit_threadsafe(event)
except Exception as e: # never let a callback kill the RNS thread
self._emit_threadsafe({"event": "error", "where": "delivery", "detail": str(e)})
def _emit_threadsafe(self, obj: dict):
self.loop.call_soon_threadsafe(self._broadcast, obj)
def _broadcast(self, obj: dict):
line = (json.dumps(obj) + "\n").encode("utf-8")
for w in list(self.clients):
try:
w.write(line)
except Exception:
self.clients.discard(w)
# ---- RPC server ----
async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
self.clients.add(writer)
writer.write((json.dumps({
"event": "ready", "dest_hash": self.dest_hash_hex,
"display_name": self.args.display_name,
}) + "\n").encode())
try:
async for raw in reader:
line = raw.decode("utf-8", "replace").strip()
if not line:
continue
try:
req = json.loads(line)
except json.JSONDecodeError:
continue
await self._dispatch(req, writer)
finally:
self.clients.discard(writer)
async def _dispatch(self, req: dict, writer: asyncio.StreamWriter):
cmd = req.get("cmd")
if cmd == "send":
self._send(req)
elif cmd == "announce":
self.announce()
elif cmd == "status":
self._broadcast(self._status())
elif cmd == "send_resource":
self._send_resource(req)
elif cmd == "shutdown":
self.loop.stop()
# unknown verbs are ignored by contract
def _status(self) -> dict:
ifaces = []
if self.reticulum is not None:
try:
ifaces = [str(i) for i in self.reticulum.get_interface_stats().get("interfaces", [])]
except Exception:
pass
return {"event": "status", "connected": self.router is not None,
"dest_hash": self.dest_hash_hex, "interfaces": ifaces}
def _send(self, req: dict):
import RNS
import LXMF
try:
dest_hash = bytes.fromhex(req["dest_hash"])
except (KeyError, ValueError):
return
recipient_identity = RNS.Identity.recall(dest_hash)
if recipient_identity is None:
# No path/identity yet — ask the network and drop this attempt; the Rust
# side retries once the peer is reachable (mirrors LoRa "unreachable").
RNS.Transport.request_path(dest_hash)
self._broadcast({"event": "delivered", "dest_hash": req["dest_hash"],
"state": "failed", "id": "", "reason": "no_path"})
return
dest = RNS.Destination(recipient_identity, RNS.Destination.OUT,
RNS.Destination.SINGLE, "lxmf", "delivery")
method = {"direct": LXMF.LXMessage.DIRECT,
"opportunistic": LXMF.LXMessage.OPPORTUNISTIC,
"propagated": LXMF.LXMessage.PROPAGATED}.get(
req.get("method", "direct"), LXMF.LXMessage.DIRECT)
# Native LXMF FIELD_IMAGE — for a stock Sideband/NomadNet peer, which
# has no idea how to decode our own typed-envelope wire format. Rust
# only sets these two keys when the peer isn't an archy contact (see
# `is_archy_peer` gating in typed_messages.rs); [format, bytes] is the
# wire shape confirmed against Sideband's own source.
fields = {}
if req.get("image_b64") and req.get("image_format"):
fields[LXMF.FIELD_IMAGE] = [req["image_format"], base64.b64decode(req["image_b64"])]
msg = LXMF.LXMessage(dest, self.delivery_destination,
req.get("content", ""), req.get("title", ""),
desired_method=method, fields=fields or None)
msg.register_delivery_callback(lambda m: self._emit_threadsafe(
{"event": "delivered", "dest_hash": req["dest_hash"], "state": "delivered",
"id": m.hash.hex() if m.hash else ""}))
msg.register_failed_callback(lambda m: self._emit_threadsafe(
{"event": "delivered", "dest_hash": req["dest_hash"], "state": "failed",
"id": m.hash.hex() if m.hash else ""}))
self.router.handle_outbound(msg)
# ---- Resource transfer (large binary payloads over a dedicated Link) ----
def _resource_dest_hash_for(self, lxmf_dest_hash: bytes):
"""Derive a peer's *resource* destination hash from their LXMF delivery
hash. Requires having already recalled their Identity (e.g. via a prior
`_send`/announce) — returns None if we haven't heard from them yet."""
import RNS
identity = RNS.Identity.recall(lxmf_dest_hash)
if identity is None:
return None
return RNS.Destination.hash(identity, "archy", "resource")
def _get_or_create_link(self, lxmf_dest_hash: bytes):
import RNS
identity = RNS.Identity.recall(lxmf_dest_hash)
if identity is None:
RNS.Transport.request_path(lxmf_dest_hash)
return None, None
resource_hash = RNS.Destination.hash(identity, "archy", "resource")
link = self.links.get(resource_hash)
if link is not None and link.status != RNS.Link.CLOSED:
return link, resource_hash
out_dest = RNS.Destination(
identity, RNS.Destination.OUT, RNS.Destination.SINGLE,
"archy", "resource",
)
link = RNS.Link(out_dest)
link.set_link_closed_callback(
lambda lk: self.links.pop(resource_hash, None)
)
self.links[resource_hash] = link
return link, resource_hash
def _send_resource(self, req: dict):
import RNS
req_id = req.get("id", "")
try:
lxmf_dest_hash = bytes.fromhex(req["dest_hash"])
data = base64.b64decode(req["data_b64"])
except (KeyError, ValueError, TypeError):
self._emit_threadsafe({"event": "resource_failed", "id": req_id,
"reason": "bad_request"})
return
link, resource_hash = self._get_or_create_link(lxmf_dest_hash)
if link is None:
self._emit_threadsafe({"event": "resource_failed", "id": req_id,
"reason": "no_path"})
return
self.pending_resource_sends.setdefault(resource_hash, [])
if link.status == RNS.Link.ACTIVE:
self._start_resource(link, data, req_id)
else:
# Link is establishing — queue and flush from the established
# callback. set_link_established_callback only fires once per Link
# object (the cache is reused across sends), so re-set it here to
# make sure THIS send's queue entry gets flushed too.
self.pending_resource_sends[resource_hash].append((data, req_id))
link.set_link_established_callback(
lambda lk: self._flush_pending_resource_sends(resource_hash, lk)
)
def _flush_pending_resource_sends(self, resource_hash: bytes, link):
import RNS
pending = self.pending_resource_sends.pop(resource_hash, [])
for data, req_id in pending:
if link.status == RNS.Link.ACTIVE:
self._start_resource(link, data, req_id)
else:
self._emit_threadsafe({"event": "resource_failed", "id": req_id,
"reason": "link_failed"})
def _start_resource(self, link, data: bytes, req_id: str):
import RNS
def on_progress(resource):
total = resource.get_data_size() if hasattr(resource, "get_data_size") else len(data)
transferred = int(total * resource.get_progress()) if hasattr(resource, "get_progress") else 0
self._emit_threadsafe({"event": "resource_progress", "id": req_id,
"transferred": transferred, "total": total})
def on_concluded(resource):
if resource.status == RNS.Resource.COMPLETE:
self._emit_threadsafe({"event": "resource_sent", "id": req_id})
else:
self._emit_threadsafe({"event": "resource_failed", "id": req_id,
"reason": "transfer_failed"})
RNS.Resource(data, link, callback=on_concluded, progress_callback=on_progress)
def _on_resource_link_established(self, link):
import RNS
link.set_resource_strategy(RNS.Link.ACCEPT_ALL)
link.set_resource_concluded_callback(self._on_resource_received)
def _on_resource_received(self, resource):
import RNS
try:
if resource.status != RNS.Resource.COMPLETE:
return
identity = resource.link.get_remote_identity()
# Report the peer's LXMF *delivery* hash (not the raw identity hash,
# and not the resource-aspect hash) since that's what the Rust side's
# contact table is keyed on for every other inbound event.
source_hash = (
RNS.Destination.hash(identity, "lxmf", "delivery").hex()
if identity is not None else ""
)
self._emit_threadsafe({
"event": "resource_recv",
"source_hash": source_hash,
"data_b64": base64.b64encode(resource.data).decode("ascii"),
})
except Exception as e: # never let a callback kill the RNS thread
self._emit_threadsafe({"event": "error", "where": "resource_recv",
"detail": str(e)})
async def serve(self):
sock_path = self.args.socket
if os.path.exists(sock_path):
os.unlink(sock_path)
server = await asyncio.start_unix_server(self._handle_client, path=sock_path)
os.chmod(sock_path, 0o600)
self.announce()
async with server:
await server.serve_forever()
class _AnnounceHandler:
"""Surfaces every heard LXMF delivery announce to the Rust side."""
aspect_filter = "lxmf.delivery"
def __init__(self, daemon: "ReticulumDaemon"):
self.daemon = daemon
self.receive_path_responses = True
def received_announce(self, destination_hash, announced_identity, app_data):
self.daemon._emit_threadsafe({
"event": "announce",
"dest_hash": destination_hash.hex(),
"app_data": (app_data or b"").hex(),
})
# ─────────────────────────────── entrypoints ─────────────────────────────────
def _parse_args(argv):
p = argparse.ArgumentParser(description="Archipelago Reticulum daemon")
p.add_argument("--identity-key", required=True, help="path to Archy node_key (32-byte ed25519 seed)")
p.add_argument("--socket", default="/tmp/archy-reticulum.sock", help="Unix RPC socket path")
p.add_argument("--rns-config", default=str(Path.home() / ".archy-reticulum"), help="RNS config/storage dir")
p.add_argument("--serial-port", help="RNode serial device, e.g. /dev/reticulum-radio")
p.add_argument("--tcp-listen", default=None, metavar="HOST:PORT",
help="Bind a plain-TCP Reticulum server interface (dev/verification "
"only; loopback-only, e.g. 127.0.0.1:4242).")
p.add_argument("--tcp-connect", action="append", default=None, metavar="HOST:PORT",
help="Dial a plain-TCP Reticulum client interface (repeatable).")
p.add_argument("--display-name", default="Archy", help="LXMF display name")
p.add_argument("--archy-ed-pubkey-hex", default=None,
help="Archy ed25519 pubkey hex (64 chars) — embedded in the announce "
"app_data as ARCHY:2:... so peers bind this RNS destination onto "
"the existing Archy contact. Omit for a plain display-name announce.")
p.add_argument("--archy-x25519-pubkey-hex", default=None,
help="Archy x25519 pubkey hex (64 chars), paired with --archy-ed-pubkey-hex.")
# LoRa profile (defaults are EU_868-ish; settled by the Phase-0 spike)
p.add_argument("--frequency", type=int, default=869525000)
p.add_argument("--bandwidth", type=int, default=125000)
p.add_argument("--txpower", type=int, default=17)
p.add_argument("--spreadingfactor", type=int, default=8)
p.add_argument("--codingrate", type=int, default=5)
p.add_argument("--no-radio", action="store_true", help="bring up with no RNode (selftest)")
p.add_argument("--check", action="store_true", help="print derived dest hash and exit (no RNS)")
p.add_argument("--selftest", action="store_true", help="bring up RNS+LXMF with no radio, verify, exit")
return p.parse_args(argv)
def _install_parent_death_signal() -> None:
"""Die when our parent process does.
The daemon ships as a PyInstaller one-file binary: our direct parent is the
bootloader, and the Rust supervisor (mesh/reticulum.rs) stops us by SIGKILL-
ing that bootloader. SIGKILL can't be forwarded, so without this the Python
child is orphaned and keeps holding the RNode serial port — which piles up
stale daemons that jam the radio (observed: 9 instances on one node). Asking
the kernel to send us SIGTERM on parent death lets our existing SIGTERM
handler shut down cleanly and free the port. Linux-only; no-op elsewhere.
"""
if sys.platform != "linux":
return
try:
import ctypes
PR_SET_PDEATHSIG = 1
ctypes.CDLL("libc.so.6", use_errno=True).prctl(
PR_SET_PDEATHSIG, signal.SIGTERM
)
except Exception:
pass # best-effort; never block startup on this
def main(argv=None) -> int:
_install_parent_death_signal()
args = _parse_args(argv if argv is not None else sys.argv[1:])
if args.serial_port and (args.tcp_listen or args.tcp_connect):
raise SystemExit(
"--serial-port is mutually exclusive with --tcp-listen/--tcp-connect "
"(one daemon process = one interface)"
)
if args.check:
seed = ReticulumDaemon._read_seed(Path(args.identity_key))
print(lxmf_destination_hash(seed).hex())
return 0
daemon = ReticulumDaemon(args)
if args.selftest:
args.no_radio = True
daemon.bring_up()
print(f"selftest ok — dest_hash={daemon.dest_hash_hex} "
f"display_name={args.display_name!r} lxmf_router=up")
return 0
for sig in (signal.SIGINT, signal.SIGTERM):
daemon.loop.add_signal_handler(sig, daemon.loop.stop)
try:
daemon.bring_up()
daemon.loop.run_until_complete(daemon.serve())
except RuntimeError:
pass # loop.stop() during serve_forever
finally:
if os.path.exists(args.socket):
os.unlink(args.socket)
return 0
if __name__ == "__main__":
sys.exit(main())
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Phase 0 gate #1 — deterministic RNS identity from the Archy seed (NO radio needed).
Proves the load-bearing assumption behind the whole "derive RNS identity from Archy
keys" decision: a node's Reticulum/LXMF destination hash is a stable, reproducible
function of its 32-byte Archy Ed25519 seed.
Run:
reticulum-daemon/.venv/bin/python reticulum-daemon/spike_identity.py
Exits non-zero (and prints FAIL) if any invariant breaks.
"""
from __future__ import annotations
import sys
from archy_rns_identity import lxmf_destination_hash, rns_private_blob
# Two fixed, non-secret test seeds (32 bytes each). Real seeds come from node_key.
SEED_A = bytes(range(32))
SEED_B = bytes((i * 7 + 3) & 0xFF for i in range(32))
def _hex(b: bytes) -> str:
return b.hex()
def main() -> int:
ok = True
# 1. The 64-byte private blob is deterministic and well-formed.
blob1 = rns_private_blob(SEED_A)
blob2 = rns_private_blob(SEED_A)
if blob1 != blob2 or len(blob1) != 64:
print(f"FAIL: private blob not deterministic/64B (len={len(blob1)})")
ok = False
else:
print(f"ok : private blob deterministic, 64B ({_hex(blob1)[:16]}…)")
# 2. Same seed -> same LXMF destination hash, across two independent builds.
h1 = lxmf_destination_hash(SEED_A)
h2 = lxmf_destination_hash(SEED_A)
if h1 != h2 or len(h1) != 16:
print(f"FAIL: destination hash not stable/16B: {_hex(h1)} vs {_hex(h2)}")
ok = False
else:
print(f"ok : destination hash stable, 16B <{_hex(h1)}>")
# 3. Different seed -> different destination (no accidental collision/constant).
h3 = lxmf_destination_hash(SEED_B)
if h3 == h1:
print(f"FAIL: distinct seeds produced the same destination <{_hex(h3)}>")
ok = False
else:
print(f"ok : distinct seed -> distinct dest <{_hex(h3)}>")
print("\nPASS — RNS identity is deterministic from the Archy seed."
if ok else "\nFAILED — see above.")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())