A private, single-owner Matrix-to-LXMF puppeting bridge
  • Python 99.7%
  • Dockerfile 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Nickie Deuxyeux 6d981f0715 Add Sideband/Columba-style profile icon appearance support
Inbound: peers sending LXMF's FIELD_ICON_APPEARANCE (MDI glyph name +
foreground/background colors) get it rendered via a vendored MDI font
into a PNG, set as both the ghost user's and portal room's Matrix
avatar. A fingerprint on the contacts table avoids re-rendering/
re-uploading on every message.

Outbound: the bridge can present its own configured icon appearance
(icon_name/icon_fg_color/icon_bg_color in [lxmf]) to LXMF peers on
every outbound message.

Also fixes room names staying as the raw hex identity when a peer's
display name hadn't been learned yet: message delivery only requires
a cached identity, not a prior announce with app_data, so the bridge
now requests the peer's path on first contact to solicit a fresh
announce instead of passively waiting for their next periodic one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 02:31:53 +03:00
bridge Add Sideband/Columba-style profile icon appearance support 2026-08-07 02:31:53 +03:00
.dockerignore Initial commit: Matrix <-> LXMF puppeting bridge 2026-08-04 23:42:34 +03:00
.gitignore Initial commit: Matrix <-> LXMF puppeting bridge 2026-08-04 23:42:34 +03:00
config.example.toml Add Sideband/Columba-style profile icon appearance support 2026-08-07 02:31:53 +03:00
Dockerfile Initial commit: Matrix <-> LXMF puppeting bridge 2026-08-04 23:42:34 +03:00
pyproject.toml Add Sideband/Columba-style profile icon appearance support 2026-08-07 02:31:53 +03:00
README.md Add Sideband/Columba-style profile icon appearance support 2026-08-07 02:31:53 +03:00

matrix-lxmf

A private, single-owner Matrix ↔ LXMF puppeting bridge. It runs as a Matrix appservice and lets one Matrix account exchange messages with contacts on the LXMF/Reticulum network from inside normal Matrix rooms.

Each LXMF contact is puppeted by a "ghost" Matrix user (@<ghost_prefix><hash>:yourserver) in a 1:1 portal room. Messages sent by the configured owner in a portal room are relayed out over LXMF; messages received over LXMF appear as messages from the ghost user in Matrix.

Features

  • One portal room per LXMF contact, created automatically on first contact
  • A management room for bridge commands (connect, disconnect, list, ping, status, identity export/import, ...)
  • Image/file/audio/video attachments in both directions, with automatic image downscaling/recompression to WebP for outbound Matrix → LXMF transfers
  • Contact profile pictures: peers sending a Sideband/Columba-style icon appearance (LXMF field FIELD_ICON_APPEARANCE) get it rendered as a Matrix avatar for their ghost user and portal room; the bridge can also present its own configured icon appearance to LXMF peers on outbound messages
  • LXMF identity export/import (Sideband/NomadNet-compatible base32 keys)
  • SQLite or PostgreSQL storage backend
  • Can attach to an existing Reticulum config/shared instance instead of spinning up its own

Requirements

  • Python 3.11+
  • A Matrix homeserver you can register an appservice on (e.g. Synapse)
  • Access to a Reticulum network (via a local interface or a shared rnsd instance)

Installation

pip install .
# or, for PostgreSQL support:
pip install .[postgres]

A Dockerfile is also provided; it installs with the postgres extra and runs python -m bridge.main --config /data/config.toml.

Configuration

The easiest way to get a working config is matrix-lxmf-bridge init, which generates fresh as_token/hs_token secrets, writes config.toml, and creates the bridge's persistent LXMF identity so its address is known up front:

matrix-lxmf-bridge init \
  --homeserver-url https://matrix.example.org \
  --server-name example.org \
  --owner-mxid @you:example.org

Run matrix-lxmf-bridge init --help for the full set of options (bind host/port, public URL, app ID, bot localpart, ghost prefix, ...); anything not passed falls back to a sane default. Re-running init refuses to overwrite an existing config.toml unless you pass --force.

Alternatively, copy config.example.toml to config.toml by hand and fill in your values (each field is documented inline). Either way, config.toml is gitignored since it holds secrets.

Once you have a config.toml:

  1. Run the bridge once; it writes registration.yaml next to your config file, generated from the as_token/hs_token/app_id in it.
  2. Add that registration.yaml to your homeserver's appservice registrations (for Synapse, list its path under app_service_config_files in homeserver.yaml) and restart the homeserver.
  3. Restart the bridge.

Running

matrix-lxmf-bridge run --config config.toml

The run subcommand can be omitted for backwards compatibility (matrix-lxmf-bridge --config config.toml still works, as used by the Dockerfile).

On first run, the bridge creates a DM "management room" with the configured owner_mxid and invites you to it.

Running with Docker

docker build -t matrix-lxmf .

The image's entrypoint is python3 -m bridge.main, defaulting to run --config /data/config.toml, so mount a host directory at /data for config, registration, and persistent state:

mkdir -p /opt/matrix-lxmf/data

# One-time: generate config.toml + LXMF identity into that directory.
docker run --rm -v /opt/matrix-lxmf/data:/data matrix-lxmf init \
  --config /data/config.toml \
  --homeserver-url https://matrix.example.org \
  --server-name example.org \
  --owner-mxid @you:example.org

init's generated config.toml defaults storage_dir, identity_file, and sqlite_path to relative paths (var/lxmf, var/lxmf/identity, var/bridge.db). Inside the container these resolve against the image's WORKDIR (/app), not /data — left as-is, your LXMF identity and database would live outside the mounted volume and vanish when the container is removed. Edit those three paths in /opt/matrix-lxmf/data/config.toml to live under /data (e.g. storage_dir = "/data/var/lxmf", identity_file = "/data/var/lxmf/identity", sqlite_path = "/data/var/bridge.db") before starting the container for real.

This bridge always attaches to an existing Reticulum shared instance (bridge/lxmf_side.py calls RNS.Reticulum(..., require_shared_instance=True) unconditionally — it never runs its own network interfaces). On Linux, that attachment defaults to an abstract AF_UNIX socket, which is scoped per network namespace. Docker's default bridge networking gives the container its own namespace, so it cannot see a shared instance (e.g. rnsd) running on the host — the container needs --network host to share the host's network namespace instead. If you're pointing reticulum_config_dir at a host Reticulum config (e.g. /etc/reticulum), bind-mount it read-only — but overlay two writable directories at <configdir>/storage/resources and <configdir>/storage/ratchets:

mkdir -p /opt/matrix-lxmf/rns-storage-resources /opt/matrix-lxmf/rns-storage-ratchets

docker run -d \
  --name matrix-lxmf \
  --restart unless-stopped \
  --network host \
  -v /opt/matrix-lxmf/data:/data \
  -v /etc/reticulum:/etc/reticulum:ro \
  -v /opt/matrix-lxmf/rns-storage-resources:/etc/reticulum/storage/resources \
  -v /opt/matrix-lxmf/rns-storage-ratchets:/etc/reticulum/storage/ratchets \
  matrix-lxmf

These two overlays are required, not optional: even when require_shared_instance=True, RNS.Reticulum() still writes locally to storage/resources/ (the reassembly buffer for any incoming transfer that doesn't fit in a single packet) and storage/ratchets/ (forward-secrecy ratchet cleanup) from within the client process itself. Mounting the whole config dir :ro without these overlays makes the bridge silently fail to reassemble any large incoming LXMF message (FileNotFoundError in storage/resources/... in the container logs) while small messages keep working fine.

Don't overlay the whole storage/ directory — only these two subdirectories. The rest of storage/ (notably transport_identity) must stay shared with the host's rnsd: RNS derives its shared-instance RPC auth key from transport_identity, so a container-private copy generates a different key and breaks the shared-instance RPC channel (used for interface/path-table stats) with multiprocessing.context.AuthenticationError: digest sent was rejected.

With --network host, bind_host = "127.0.0.1" in config.toml refers to the host's own loopback, so Synapse on the same host can reach it there without further port mapping.

Management room commands

Command Description
help Show help
status Check RNS/shared instance connectivity
connect <hash> [display name] Start a new conversation with an LXMF address
disconnect <hash> Remove a specific portal
disconnect-all Remove all portals
list List all portals
ping <hash> Probe reachability to an LXMF address
export-identity Print the bridge's LXMF private key (base32)
import-identity <base32 key> Replace the bridge's LXMF identity (or just send a .rnsidentity file)

Portal room commands

Prefixed with !lxmf inside portal rooms:

Command Description
!lxmf delete-portal Remove this portal
!lxmf ping Probe reachability to this contact
!lxmf show-hash Print the bridge's own LXMF address

License

No license has been chosen yet for this project.