Plugins¶
RelayFabric never speaks a native protocol itself. Each bridged network is
owned by a plugin — a separate process the switchyardd daemon spawns,
supervises, and talks to over a small IPC. This page covers the plugin
model shared by every bridge, then one section per shipped plugin: what
network it bridges, its config keys, its licensing posture, and (where the
plugin's own README documents them) known field-test risks. For the full
config schema see Configuration; to write a new plugin,
see Plugin Authoring; for how these plugins were
exercised against real infrastructure, see
Live & Field Testing.
The plugin model¶
Separate, supervised processes. A plugin is whatever the daemon's
command: spawns — a Rust binary, a Python script, anything that speaks
the wire protocol. The daemon restarts a plugin that dies unexpectedly with
bounded backoff (1s, 5s, 30s, 2m, ...); a plugin that keeps crashing is
marked unhealthy rather than restarted forever.
CBOR-over-Unix-socket IPC. The daemon spawns each plugin with
RELAYFABRIC_SOCKET (the Unix domain socket path), RELAYFABRIC_PLUGIN_NAME,
and RELAYFABRIC_PLUGIN_CONFIG (the plugin's config: block as JSON) in its
environment. Every frame on the socket is a 4-byte big-endian length prefix
followed by a CBOR body, with a t tag identifying the frame
(hello, hello_ack, inbound, send, send_direct, delivery_result,
shutdown). Wire bytes are golden-locked across implementations, so a Rust
plugin (relay-core + relay-ipc) and a Python plugin (relayfabric_sdk)
interoperate exactly.
Capability advertisement (Hello). Before anything else, a plugin sends
one Hello { plugin, version, protocol_version, capabilities } frame and
waits for HelloAck; a non-null error means the daemon rejected it and it
must exit rather than proceed. Capabilities (text, direct_messages,
groups, attachments, location, reactions, receipts, presence,
max_payload) aren't cosmetic — the daemon uses them to route and to gate
features. direct_messages in particular gates whether a plugin ever
receives a send_direct frame (used today for identity-link challenge
delivery).
Deny-by-default. A plugin only subscribes to, and only accepts sends for, the channels/topics/filters explicitly present in its config — unconfigured native traffic is never bridged. Several plugins add a second, protocol-specific layer of this: Nostr and Bitchat recompute and verify every inbound event's signature before bridging it, since a relay is untrusted infrastructure.
Content never logged. Default daemon logs carry operational metadata (message id, route, source protocol) and never message bodies — privacy tests assert this holds even with linked identities and location data in play.
MQTT¶
The in-tree Rust reference plugin (relayfabric-mqtt), and also a useful
integration/testing transport in its own right (spec §8). Bridges arbitrary
MQTT topics over MQTT v5: each subscribed topic doubles as the fabric
endpoint name, and there is no separate channel-mapping layer — topics
is the config. Delivery results are reported only once the broker's QoS 1
PUBACK arrives, not merely on local enqueue. Loop prevention here is
transport-level: subscriptions set MQTT v5's No Local flag so the broker
never echoes the plugin's own publishes back to it.
| Key | Default | Notes |
|---|---|---|
broker |
— | mqtt://host[:port]; port defaults to 1883 |
topics |
[] |
Topics to subscribe; each topic name is also the endpoint |
client_id |
"relayfabric" |
MQTT client identifier |
plugins:
mqtt:
enabled: true
command: relayfabric-mqtt
config:
broker: mqtt://127.0.0.1:1883
topics: [chat/a, chat/b]
Licensing
Built on rumqttc, an MIT/Apache-2.0 async MQTT client. No GPL
dependency in this plugin's path.
LXMF¶
Bridges LXMF over Reticulum (RNS). Each configured channel maps to a set of LXMF destination hashes ("members"): inbound messages from a member land on the channel, and messages sent to the channel fan out as direct LXMF messages to every member, falling back to a propagation node (store-and-forward) if direct delivery fails.
| Key | Default | Notes |
|---|---|---|
display_name |
— | Shown in the gateway's Reticulum announce |
storage |
— | Directory for Reticulum/LXMF state and dynamic membership |
rns_configdir |
null |
null = default ~/.reticulum |
announce_interval |
3600 |
Seconds between RNS announces |
stamp_cost |
null |
Set to require inbound proof-of-work stamps |
propagation_node |
"auto" |
"auto" | explicit dest hash hex | null |
max_attachment_bytes |
1000000 |
Per-attachment cap, applied both directions |
image_max_bytes |
null |
Falls back to max_attachment_bytes |
voice_to_codec2 |
null |
codec2 bitrate to transcode outbound voice, e.g. 1200 |
channels |
— | name / members (lowercase LXMF dest hashes) / open |
plugins:
lxmf:
enabled: true
command: /path/to/.venv/bin/python /path/to/plugins/lxmf/relayfabric-lxmf
config:
storage: /var/lib/relayfabric/lxmf
channels:
- name: pasadena
members: ["a91d00aa..."]
open: false
Licensing
Depends on the official rns/lxmf Python packages and rides
Reticulum's open mesh transport — no proprietary or copyleft coupling
in the bridge itself.
Optional extras (Pillow for image downscaling; ffmpeg + pycodec2 for
voice-to-codec2 transcoding) degrade gracefully when absent — oversize
images or untranscoded voice fall back to plain file attachments rather
than failing the send.
Signal¶
Bridges Signal groups: each configured group maps to a channel, one
endpoint per group (fan-out across groups is the daemon's routing concern,
not this plugin's). Talks to a locally-run signal-cli daemon over
JSON-RPC/SSE rather than embedding Signal's protocol directly.
| Key | Default | Notes |
|---|---|---|
account |
— | Gateway's registered phone number |
rpc_url |
— | signal-cli daemon HTTP URL |
groups |
— | channel: group_id map (IDs from signal-cli listGroups) |
allowed_users |
null |
null = all members; else a list of UUIDs |
attachment_dir |
~/.local/share/signal-cli/attachments |
signal-cli's download dir |
max_attachment_bytes |
8000000 |
Per-attachment cap, applied both directions |
plugins:
signal:
enabled: true
command: /path/to/.venv/bin/python /path/to/plugins/signal/relayfabric-signal
config:
account: "+1234567890"
rpc_url: http://127.0.0.1:7583
groups:
pasadena: "GRP=="
Licensing
The plugin process itself has no GPL dependency; it speaks JSON-RPC to
a separately-run signal-cli daemon over HTTP rather than linking any
Signal client library.
If the account is a linked device, sync-message echoes of the gateway's
own posts are filtered and bare DMs (no group) are dropped; if
allowed_users is set, the account's own UUID must be included or its
posts are dropped by the ACL. Attachments cross as opaque bytes — no
downscaling or transcoding happens here (that lives in the LXMF plugin,
for messages routed onward there).
Meshtastic¶
Bridges Meshtastic device channels. Each configured channel maps to a RelayFabric channel: text uplinks arrive over the node's MQTT JSON stream, and messages sent to the channel are delivered back over MQTT (one topic per Meshtastic channel).
Licensing
Meshtastic's official Python/protobuf client libraries are GPL-3.0.
This plugin never links or imports them — it consumes the device's
native MQTT JSON integration instead, an interface the node
operator enables and controls independently. The meshtastic CLI used
to configure the device (meshtastic --set mqtt.json_enabled true)
runs on the operator's own machine, never on the gateway.
| Key | Default | Notes |
|---|---|---|
broker |
— | mqtt://host:port |
topic_root |
— | Node's full MQTT root incl. region, e.g. msh/US |
gateway_id |
null |
null = accept all gateways; else filter by hex ID |
max_text_bytes |
200 |
Truncation applied to upstream text |
channels |
— | index / topic_channel per named channel |
plugins:
meshtastic:
enabled: true
config:
broker: mqtt://127.0.0.1:1883
topic_root: msh/US
channels:
zone1: {index: 0, topic_channel: "general"}
A consume-on-match cache keyed on (channel, text) breaks the
MQTT→Meshtastic→MQTT loop: a successful downlink is remembered for 1 hour
and the next matching uplink is dropped instead of re-bridged.
Known field-test risks
- Some firmware validates the downlink
fromfield; this plugin always sendsfrom: 0.delivered: trueonly means the broker accepted the publish, not that the node transmitted it — verify one real downlink per firmware. See Live & Field Testing. payload.timestampis assumed to already be epoch seconds.- A lost echo (downlink never re-uplinks over the air) leaves the loop-guard entry live for its full 1-hour TTL, during which one identical genuine uplink can be swallowed.
MeshCore¶
Bridges MeshCore (Companion Radio Protocol) devices directly — no intermediary broker. Each configured channel maps to a RelayFabric channel; text uplinks land on the channel, sent messages go back over the radio. Requires companion-mode firmware.
Licensing
Uses the native meshcore library (2.3.8, MIT) — spec §8's preferred
Companion Radio Protocol backend — talking directly to a companion-mode
radio rather than wrapping a user-facing app.
| Key | Default | Notes |
|---|---|---|
connection |
— | serial://path[?baud=N] | tcp://host:port | ble://addr (best-effort, untested) |
max_text_bytes |
160 |
Budget for alias tag + body combined |
channels |
— | index per named channel |
plugins:
meshcore:
enabled: true
config:
connection: serial:///dev/ttyUSB0
channels:
primary: {index: 0}
Sender identity is channel-scoped, not per-node: MeshCore PSK channels
carry no per-node identity, so the plugin keys sender on (channel index) —
every message on a channel maps to the same sender. Rate limits, aliases,
and moderation therefore all operate at channel granularity, not per-user.
Known field-test risks
- The library API is exercised only against fakes — verify end-to-end on hardware: one inbound channel message reaching the daemon, and one max-length send. See Live & Field Testing.
- Re-verify per connection kind when switching serial/tcp/ble.
- Alias prefix collision with Meshtastic: both alias as
MESH-XXXX(first four protocol-name chars) — cosmetic, aliases stay distinct per sender. tstimestamp units are assumed to be epoch seconds.
Nostr¶
Bridges Nostr relays natively over NIP-01 WebSockets — no intermediary
broker. Each channel is a (relay-set, filter) pair for inbound plus a
publish target. Scope: kind-1 public text notes only — encrypted DMs,
attachments, and profile/contact-list management are out of scope this
cycle.
Licensing
NIP-01 crypto and relay I/O run on coincurve + websockets
(MIT/BSD-3) — never the GPL strfry relay, which is reference-only per
project policy.
| Key | Default | Notes |
|---|---|---|
identity_file |
null |
Persists the plugin's keypair (mode 0600); null = fresh identity every restart |
relays |
— | Default relay set (wss:// URLs) |
channels |
— | Per channel: relays (optional, else default), filter (NIP-01 REQ), publish_tags |
max_text_bytes |
280 |
Outbound text budget |
plugins:
nostr:
enabled: true
config:
identity_file: /var/lib/relayfabric/nostr.nsec
relays: ["wss://relay.example.com"]
channels:
regional:
filter: {kinds: [1], "#t": ["pasadena"]}
publish_tags: [["t", "pasadena"]]
Every inbound event's id is recomputed and its schnorr signature verified against the claimed pubkey before bridging — a relay is untrusted; bad id/sig events are dropped, never bridged.
Known field-test risks
- No cross-relay dedup: an event seen from two subscribed relays isn't deduplicated. Exercised only against fakes so far — see Live & Field Testing.
- An unscoped filter (e.g. bare
{"kinds":[1]}) bridges relay-wide traffic as spam; the operator is responsible for scoping it. created_atis relay/author-supplied and not checked against wall clock.- Sender identity (
nostr:<pubkey hex>) is stable per author but not human-friendly; a rotated keypair reads as an entirely new sender.
Bitchat¶
Bridges Bitchat's public geohash channels over the Internet/Nostr
transport only — ephemeral kind-20000 events, channel = ["g", <geohash>]
tag. Reuses the shipped Nostr NIP-01 crypto and relay machinery. BLE mesh
is out of scope, deferred — Bitchat's direct Bluetooth-LE mesh is a
separate mechanism not built here. DMs and attachments are out of scope
too.
Licensing
Same crypto stack as the Nostr plugin — coincurve + websockets
(MIT/BSD-3) — never the GPL strfry relay or the AGPL NYM
Bitchat-Nostr bridge, both reference-only per project policy.
| Key | Default | Notes |
|---|---|---|
identity_file |
null |
One stable keypair authors every outbound event on every configured geohash |
relays |
— | Default relay set (wss:// URLs) |
channels |
— | Per channel: geohash (base32), relays (optional), nickname (optional, passthrough n-tag) |
max_text_bytes |
280 |
Outbound text budget |
plugins:
bitchat:
enabled: true
config:
identity_file: /var/lib/relayfabric/bitchat.nsec
relays: ["wss://relay.example.com"]
channels:
pasadena:
geohash: "9q5c"
nickname: "relayfabric"
Same sig-verify-before-bridge and deny-by-default rules as Nostr apply (bad id/sig, wrong-kind, or wrong-geohash events are dropped; only configured geohashes are subscribed).
Known field-test risks
- Pre-1.0 protocol: kind 20000 and the
g-tag geohash are stable, but nickname/teleport semantics are not — expect churn. - Events are ephemeral and unstored — bridging only works with a live relay connection at publish time.
- One key per gateway means the same pubkey appears on every geohash it posts to, cross-geohash-linkable to anyone watching a relay. Real Bitchat clients avoid this with per-geohash ephemeral keys, deferred here pending a documented derivation.
- Interop with real Bitchat clients is unverified — fakes only, no live cross-check yet. See Live & Field Testing.
- A coarser (shorter) geohash is a wider channel — more traffic bridged.