{"id":51161557,"url":"https://github.com/thatsfguy/reticulum-group-chat","last_synced_at":"2026-06-26T14:01:42.160Z","repository":{"id":356111700,"uuid":"1230484497","full_name":"thatSFguy/reticulum-group-chat","owner":"thatSFguy","description":"Reticulum LXMF group-chat forwarding service","archived":false,"fork":false,"pushed_at":"2026-06-20T09:57:52.000Z","size":609,"stargazers_count":2,"open_issues_count":0,"forks_count":1,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-20T11:11:41.080Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/thatSFguy.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-06T03:27:12.000Z","updated_at":"2026-06-20T09:57:55.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/thatSFguy/reticulum-group-chat","commit_stats":null,"previous_names":["thatsfguy/reticulum-forwarding-service","thatsfguy/reticulum-group-chat"],"tags_count":23,"template":false,"template_full_name":null,"purl":"pkg:github/thatSFguy/reticulum-group-chat","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thatSFguy%2Freticulum-group-chat","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thatSFguy%2Freticulum-group-chat/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thatSFguy%2Freticulum-group-chat/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thatSFguy%2Freticulum-group-chat/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/thatSFguy","download_url":"https://codeload.github.com/thatSFguy/reticulum-group-chat/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thatSFguy%2Freticulum-group-chat/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34819597,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-26T02:00:06.560Z","response_time":106,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-26T14:01:40.669Z","updated_at":"2026-06-26T14:01:42.137Z","avatar_url":"https://github.com/thatSFguy.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# reticulum-group-chat (`fwdsvc`)\n\n**A Reticulum group chat.** `fwdsvc` is a small daemon that hosts a\nmulti-user text chat over the [Reticulum\nNetwork](https://reticulum.network), using [LXMF](https://github.com/markqvist/LXMF)\nfor message delivery. Each chat is one running service. Anyone with the\nservice's destination hash can `/join`, send messages, and have them\nfanned out to every other member — a many-to-many group chat over\nwhatever Reticulum transport(s) you have available (LoRa, TCP/IP, your\nown mesh, etc.).\n\nIf you've used IRC, Matrix, or Telegram groups, the user experience is\nsimilar. The difference is that the chat travels over Reticulum, so it\nworks on radios with no Internet, can be relayed across mixed\nLoRa+TCP+I2P meshes, and the operator is just one person running this\nbinary on a Pi.\n\n- **No third-party Reticulum library.** Pure-Go implementation of the\n  protocol layers we need, written directly against\n  [the spec](https://github.com/thatSFguy/reticulum-specifications).\n- **Verified against upstream Python `rns` + `LXMF`** at the byte level\n  (static test vectors plus a live subprocess interop harness running\n  on CI).\n- **Live-tested end-to-end** with Sideband, NomadNet, and MeshChat over\n  public Reticulum testnet entry nodes.\n- **One static binary** — runs unattended on a Raspberry Pi, a Debian\n  server, macOS, or Windows. No runtime, no Python, no daemon zoo.\n\n---\n\n## Table of contents\n\n1. [What this is, and how it works](#what-this-is-and-how-it-works)\n2. [Features](#features)\n3. [Install and first run](#install-and-first-run)\n4. [Commands](#commands)\n5. [Configuration reference](#configuration-reference)\n6. [Deployment recipes](#deployment-recipes)\n7. [Operations](#operations)\n8. [Wire-format support](#wire-format-support)\n9. [Reactions: what a client must implement](#reactions-what-a-client-must-implement)\n10. [Limitations](#limitations)\n11. [Build from source](#build-from-source)\n12. [Project info](#project-info)\n\n---\n\n## What this is, and how it works\n\nReticulum gives every participant a cryptographic **identity** — a\nkeypair stored in a small file. From the identity you derive a\n**destination hash**, a 16-byte address other peers route to. Reticulum\n**announces** propagate destination → identity bindings across the\nmesh so anyone in range can encrypt to anyone else.\n\nLXMF rides on top of Reticulum: signed, encrypted, store-and-forward\nmessages addressed to a destination hash, deliverable opportunistically\n(one Reticulum packet) or over a Reticulum **Link** for larger payloads.\n\n`fwdsvc` is one LXMF endpoint that behaves like a chatroom:\n\n```\n┌──────────┐                ┌──────────┐                ┌──────────┐\n│ Alice    │   /join, msg   │ fwdsvc   │   forwarded    │ Bob      │\n│ Sideband │ ─────────────▶ │ daemon   │ ─────────────▶ │ NomadNet │\n└──────────┘                │          │                └──────────┘\n                            │ roster:  │\n┌──────────┐                │  Alice   │                ┌──────────┐\n│ Carol    │   /join, msg   │  Bob     │   forwarded    │ Dave     │\n│ MeshChat │ ─────────────▶ │  Carol   │ ─────────────▶ │ Sideband │\n└──────────┘                │  Dave    │                └──────────┘\n                            └──────────┘\n```\n\n- Each message Alice sends to the daemon's destination hash is\n  forwarded to every other member of the roster, prefixed with\n  `[Alice] ` so receivers see who said what.\n- The daemon never sees plaintext from any peer except via its own\n  identity's decryption, and it re-encrypts per recipient on the way\n  out — same trust model as any LXMF peer.\n- Joining is by sending `/join`. Roster membership is auto-pruned for\n  anyone not heard from (no announce, no message) for four weeks, and for\n  lurkers who keep announcing but never send a chat message for six weeks,\n  by default.\n\n### Glossary\n\n| Term | Meaning |\n|---|---|\n| **Identity** | 64-byte X25519 + Ed25519 keypair on disk (or in `service.identity_b64`). Lost identity = lost destination hash = your roster has to re-add you. |\n| **Destination hash** | 16-byte / 32-hex-char address. The thing your users put in their LXMF client to message the service. |\n| **Announce** | A signed broadcast that teaches the mesh \"destination `D` belongs to identity `I`, reachable via these hops.\" `fwdsvc` re-announces itself every `announce_interval` (default 10 min). |\n| **Roster** | The set of members. Living in `state.json`. |\n| **Replay** | When a new member joins, the daemon ships them the most recent N messages so they can pick up the conversation. |\n| **Opportunistic / Link / Resource** | The three LXMF delivery paths, picked automatically by payload size. See [Wire-format support](#wire-format-support). |\n\n---\n\n## Features\n\n- **Explicit `/join`** opt-in. A first message from a stranger gets a\n  private invitation reply, not a forward. Avoids the \"I sent one test\n  message and now strangers are getting it\" UX.\n- **Default nickname from announces.** A member with no nickname set\n  picks one up automatically from their announced display name,\n  sanitised to `[A-Za-z0-9_-]{1,24}` (`\"Bob \u0026 Alice\"` → `\"Bob_Alice\"`;\n  all-emoji collapses to empty and stays unset). Applied at `/join`\n  time and on every subsequent announce until a nickname exists, so a\n  user who joined before their first announce arrived still gets named\n  once it does. `/nick` is authoritative: once set, announces never\n  overwrite.\n- **Replay on join.** New (and returning) members receive the most\n  recent buffered messages so they can read the prior conversation.\n  Defaults: last 100 messages, nothing older than 7 days. Configurable.\n- **Pause without leaving.** A member can `/pause` to stop receiving\n  (and sending) forwards while staying on the roster. `/resume` reverses.\n- **Per-message char cap** (`max_inbound_chars`, default 500). Oversize\n  non-command messages get a polite reject reply and aren't forwarded.\n- **Auto-prune.** Members not heard from for `prune_after` (default 4 weeks)\n  are removed automatically. A second window, `prune_silent_after` (default\n  6 weeks), also removes lurkers who keep announcing (or run the odd command)\n  but never send a chat message. Either way they can `/join` again any time.\n- **Forwarded content sanitised.** Bytes outside printable + TAB/LF/CR\n  become `?` before forwarding — no ANSI-escape injection into other\n  users' terminals.\n- **Outbound retry queue.** Every outbound message goes through a\n  persistent queue mirroring LXMF's `LXMRouter.process_outbound`\n  policy: 5 attempts at 10-second intervals, 7-second `path?`-backed\n  defer when the recipient hasn't announced. Survives restarts via\n  `outbound.json`. Drained by a 4-worker pool so a slow send to one\n  recipient doesn't block command replies to others. Picker\n  prioritises by recipient recency (peers we just heard announce go\n  first).\n- **Announce cache survives restart.** Verified announces persist to\n  `announces.json` — after a restart, every previously-known peer is\n  immediately addressable instead of waiting up to one\n  `announce_interval` for them to re-announce. Entries older than\n  30 days are dropped at load time.\n- **Three LXMF delivery paths, picked automatically.**\n  Opportunistic (one packet, fire-and-forget) for small replies; Link\n  DATA (one Token-framed packet over a Reticulum Link) for medium\n  payloads; SPEC §10 Resource transfer for anything bigger (long\n  `/users` replies on big rosters, long chat messages, etc.).\n- **Mod / admin moderation.** Config-file `admins` and `mods` lists\n  get `/kick`, `/ban`, `/unban`, `/announce`, `/path`, and the\n  cross-user form of `/nick`. Admins can also grant/clear roles at\n  runtime with `/usermode` — no config edit or restart.\n- **Bind-once identity.** Embed your identity in `config.toml` via\n  `identity_b64` and the config file is the single source of truth —\n  reinstall on any machine, same destination hash, same chat for\n  everyone.\n- **Self-healing TCP interface.** `tcp_client` interfaces auto-redial\n  with capped exponential backoff after any drop (peer restart, NAT\n  timeout, transient network failure). TCP keepalive on dialed sockets\n  surfaces silent peer drops within ~2 minutes instead of waiting for\n  the next outbound write to fail. The service does not need to be\n  restarted after an upstream blip.\n\n---\n\n## Install and first run\n\n### 1. Get the binary\n\n[Download the latest release](https://github.com/thatSFguy/reticulum-group-chat/releases/latest)\nfor your platform:\n\n| Asset | Target |\n|---|---|\n| `fwdsvc-linux-amd64`       | x86_64 Linux (Debian/Ubuntu/etc.) |\n| `fwdsvc-linux-arm64`       | ARM64 Linux (RPi 4/5 64-bit, most ARM SBCs) |\n| `fwdsvc-linux-armv7`       | 32-bit ARMv7 (RPi 2/3 32-bit) |\n| `fwdsvc-linux-armv6`       | 32-bit ARMv6 (RPi Zero, RPi 1) |\n| `fwdsvc-darwin-arm64`      | Apple Silicon macOS |\n| `fwdsvc-windows-amd64.exe` | x86_64 Windows |\n\nOn Linux/macOS, `chmod +x fwdsvc-…` after download.\n\nOr [build from source](#build-from-source).\n\n### 2. Make a config\n\n```sh\nmkdir -p ~/.fwdsvc\ncurl -L https://raw.githubusercontent.com/thatSFguy/reticulum-group-chat/main/configs/fwdsvc.example.toml \\\n  -o ~/.fwdsvc/config.toml\n```\n\nOpen `~/.fwdsvc/config.toml` and edit:\n\n- `display_name` — what your service shows in its announces. Visible\n  to every Reticulum node it reaches.\n- `[[interfaces]]` `addr` — a reachable Reticulum peer to dial. A\n  community testnet node (`rns.chicagonomad.net:4242`,\n  `rns.michmesh.net:7822`, etc.) works for getting started; for\n  production you'd point at a local `rnsd` you control or your own\n  TCP-attached gateway.\n- Leave `admins = []` for now; you'll add yourself in step 4.\n\n### 3. Run it once to generate an identity\n\n```sh\n./fwdsvc -config ~/.fwdsvc/config.toml\n```\n\nFirst lines on stdout:\n\n```\nfwdsvc 1.11.0 starting (linux/amd64)\nfwdsvc 2026/05/11 16:00:00 interface tcp_client connected: rns.chicagonomad.net:4242\nfwdsvc 2026/05/11 16:00:00 service identity hash: 359fc3967f984a529874d0960c6ee782\nfwdsvc 2026/05/11 16:00:00 delivery destination : 4c87fb86ccfdff39a3d1e22060ba1789\nfwdsvc 2026/05/11 16:00:00 display name        : My Group Chat\n```\n\nThe **delivery destination** (second hash) is the address your users\nwill message in their LXMF client. Share that with your group — it's\nthe chat's stable identifier.\n\n### 4. Add yourself as admin\n\nFrom your LXMF client (Sideband on phone, NomadNet on desktop,\nMeshChat, etc.) send the service any short message. The forwarder will\nlog:\n\n```\nnew sender contact: full dest_hash = 0b0501efed0844bb064bc6df4cba43bb\n```\n\nStop the service (Ctrl-C), put that 32-character hex string in\n`admins`, and restart:\n\n```toml\nadmins = [\n  \"0b0501efed0844bb064bc6df4cba43bb\",\n]\n```\n\n\u003e **Important:** `admins` and `mods` MUST be top-level keys in\n\u003e `config.toml`, before any `[section]` header. TOML scopes top-level\n\u003e keys to whichever section is currently active, so putting them after\n\u003e `[service]` silently makes them `service.admins`.\n\n### 5. Join from your client\n\nFrom your LXMF client, send `/join` to the daemon's delivery\ndestination. You'll get a confirmation reply and from now on every\nforwarded message from other members lands in your inbox.\n\nSend `/?` to see the commands available to you (admins see the full\nmoderation set).\n\nYour friends do the same against the same delivery destination, and\nthey're all in the chat.\n\n---\n\n## Commands\n\n`/?` (or `/help`) replies are **role-aware** — non-members only see\ncommands that work for them, mods see the moderation set, admins see\neverything.\n\n### User commands\n\n| Command | Who | Effect |\n|---|---|---|\n| `/?` or `/help`           | anyone      | List commands available to you |\n| `/about` or `/version`    | anyone      | Show version and repo URL |\n| `/users`                  | anyone      | List roster (paused members marked `[paused]`) |\n| `/mods`                   | anyone      | List configured mods |\n| `/admin`                  | anyone      | List configured admins |\n| `/join`                   | non-members | Opt in: receive forwarded messages, your messages get forwarded |\n| `/leave`                  | members     | Leave the chat (you can `/join` again later) |\n| `/pause`                  | members     | Stop receiving forwards (and stop forwarding yours) |\n| `/resume`                 | members     | Reverse `/pause` |\n| `/textonly`               | members     | Skip attachments — receive only the text body of forwarded messages. Intended for users on slow / metered links. |\n| `/showall`                | members     | Reverse `/textonly` — resume receiving attachments. |\n| `/nick \u003cnewname\u003e`         | members     | Change own nickname (1–24 chars from `[A-Za-z0-9_-]`) |\n\n### Mod / admin commands\n\n| Command | Who | Effect |\n|---|---|---|\n| `/nick \u003cuser\u003e \u003cnewname\u003e`  | mods, admins | Change another user's nickname |\n| `/kick \u003cuser\u003e`            | mods, admins | Remove from roster (user can `/join` again) |\n| `/ban \u003cuser\u003e`             | mods, admins | Add to banlist; future `/join`s and messages refused |\n| `/unban \u003cuser\u003e`           | mods, admins | Remove from banlist |\n| `/announce`               | mods, admins | Broadcast a fresh Reticulum announce immediately |\n| `/path \u003cuser\u003e`            | mods, admins | Show what the transport knows about reaching `\u003cuser\u003e`: cached announce age, hop count, next-hop transport_id, whether an Active Link is open. Mostly for troubleshooting delivery problems. |\n| `/usermode \u003cadmin\\|mod\\|user\u003e \u003cuser\u003e` | admins | Grant or clear a runtime role for a roster member — no config edit or restart needed. `admin`/`mod` promote; `user` clears the runtime grant. |\n\n`\u003cuser\u003e` accepts a **nickname** (case-insensitive) or a\n**destination-hash prefix** (≥ 4 hex chars). When two members would\nmatch the prefix, the daemon refuses with a disambiguation reply.\n\n**`/usermode` and the config lists.** A runtime role only ever *raises*\nthe role granted by the config `admins`/`mods` lists — it never lowers\nit. The effective role is `max(config role, runtime role)`. So:\n\n- `/usermode mod Alice` promotes a regular member to mod; `/usermode\n  user Alice` clears that grant again.\n- A role granted in the config file **cannot be demoted from chat** — if\n  you `/usermode user` someone who is a config admin/mod, the daemon\n  tells you their effective role is unchanged and to edit the config.\n  This is deliberate: you can't strip a config-defined admin's powers\n  from inside the room.\n- Runtime grants persist in the roster state file across restarts.\n- An admin can't clear their own runtime admin grant (anti-lockout); a\n  config-defined admin is inherently safe.\n\n### Examples\n\n```\n\u003e /join\nJoined. You'll receive forwarded messages from now on. /pause to mute,\n/leave to exit, /? for help.\n\n\u003e /nick Alice\nNickname set to Alice.\n\n\u003e /users\nUsers (3):\n  Alice — 0b0501ef\n  Bob — ffeeddcc\n  (no nick) — 1234abcd\n\n\u003e Hi everyone!\n(message fans out to Bob and the unnicked user with prefix `[Alice] Hi everyone!`)\n```\n\n---\n\n## Configuration reference\n\nThe config is a single TOML file. Default location is\n`~/.fwdsvc/config.toml`; override with `-config \u003cpath\u003e`.\n\n### Root-level keys\n\n| Key | Type | Default | Description |\n|---|---|---|---|\n| `admins` | array of hex strings | `[]` | Destination hashes of admins. Get all the mod commands. |\n| `mods`   | array of hex strings | `[]` | Destination hashes of mods. Get the moderation commands minus admin-only ones. |\n\nBoth lists MUST be declared at the top of the file, before any\n`[section]` header.\n\n### `[service]`\n\n| Key | Type | Default | Description |\n|---|---|---|---|\n| `display_name`       | string   | `\"Group Chat - send /join\"` | Shown in announces. |\n| `identity_path`      | path     | `~/.fwdsvc/identity`        | Where the service's identity is stored. Ignored if `identity_b64` is set. |\n| `identity_b64`       | string   | unset                       | Base64 of the 64-byte identity. When set, this is authoritative and `identity_path` is ignored. See [Identity backup](#identity-backup). |\n| `state_path`         | path     | `~/.fwdsvc/state.json`      | Roster + banlist. |\n| `history_path`       | path     | `~/.fwdsvc/history.json`    | Replay ring buffer. |\n| `log_path`           | path     | unset                       | If set, append the daemon log to this file (in addition to stdout). |\n| `prune_after`        | duration | `\"4w\"`                      | Drop a member we haven't heard from (no announce, no message) for this long. |\n| `prune_silent_after` | duration | `\"6w\"`                      | Drop a member who hasn't sent a chat message in this long, even if still announcing or running commands (`/join` resets it). `0` disables. |\n| `prune_interval`     | duration | `\"1h\"`                      | How often the prune sweep runs. |\n| `announce_interval`  | duration | `\"10m\"`                     | How often we re-announce ourselves. |\n| `max_inbound_chars`  | int      | `500`                       | Reject non-command messages longer than this many UTF-8 chars. `0` disables. |\n| `max_members`        | int      | `0`                         | Cap on roster size. `/join` past the cap is refused. `0` = unlimited. |\n| `forward_attachments`| bool     | `true`                      | Pass LXMF non-text fields (images, etc.) through forwarding. `false` drops all attachments silently. |\n| `max_attachment_bytes`| int     | `32768`                     | Per-field msgpack size cap. Oversize attachments are dropped with an inline `[image not forwarded: …]` note; text body still delivers. `0` disables the cap. |\n| `forwarded_fields`   | int list | `[6, 48, 49, 64, 65, 66]`   | Allowlist of LXMF field keys to forward when `forward_attachments=true`. Default covers `FIELD_IMAGE` (6) plus the upstream LXMF 1.0.0 message-meta fields: reply-to (`FIELD_REPLY_TO 0x30`=48 message-id, `FIELD_REPLY_QUOTE 0x31`=49 quoted text), tap-back reactions (`FIELD_REACTION 0x40`=64), comments (`FIELD_COMMENT 0x41`=65), and continuations (`FIELD_CONTINUATION 0x42`=66). Add `5` for files, `7` for audio once your senders/receivers handle them. |\n| `id_cache_ttl`       | duration | `24h`                       | How long fwdsvc remembers each fan-out's per-recipient LXMF `message_id` so reactions and reply-to fields can be rewritten per recipient (v1.6.0+). `0` disables — reactions then show \"[someone reacted]\" without landing on a bubble. Going longer just grows memory; each cache entry is ~50 bytes × roster size. |\n| `id_cache_max`       | int      | `10000`                     | Hard cap on `id_cache_ttl` entry count (LRU evicts oldest). One fan-out to N recipients counts as N entries. `0` = unbounded. |\n\nDurations are Go `time.ParseDuration` plus `d` (days) and `w`\n(weeks): `\"30s\"`, `\"5m\"`, `\"24h\"`, `\"7d\"`, `\"4w\"`.\n\n### `[[interfaces]]`\n\nRepeated table — one entry per Reticulum I/O interface. Currently only\n`tcp_client` is supported.\n\n| Key | Type | Default | Description |\n|---|---|---|---|\n| `type`    | string   | required | `\"tcp_client\"`. |\n| `addr`    | string   | required | `host:port` of a `TCPServerInterface` peer to dial. |\n| `timeout` | duration | `\"0\"`    | Dial timeout. `0` = stdlib default (~30 s). |\n\n```toml\n[[interfaces]]\ntype    = \"tcp_client\"\naddr    = \"rns.chicagonomad.net:4242\"\ntimeout = \"10s\"\n\n[[interfaces]]\ntype = \"tcp_client\"\naddr = \"10.0.0.42:4242\"   # your own rnsd on the LAN\n```\n\n`fwdsvc` broadcasts on **all** interfaces; redundancy is fine.\n\n### `[replay]`\n\n| Key | Type | Default | Description |\n|---|---|---|---|\n| `count`   | int      | `100` | Max messages replayed when a member joins (or rejoins). `0` disables replay entirely. |\n| `max_age` | duration | `\"7d\"` | Skip messages older than this in replay. |\n\n---\n\n## Deployment recipes\n\n### Linux + systemd (recommended)\n\n1. Put the binary in `/usr/local/bin/fwdsvc` and `chmod 755` it.\n2. Create a system user:\n   ```sh\n   sudo useradd --system --home /var/lib/fwdsvc --create-home --shell /usr/sbin/nologin fwdsvc\n   ```\n3. Put `config.toml` at `/etc/fwdsvc/config.toml`. Make sure\n   `identity_path`, `state_path`, `history_path` all point under\n   `/var/lib/fwdsvc/` (e.g. `/var/lib/fwdsvc/identity`) — `~/`\n   doesn't expand under a system user.\n4. Drop this in `/etc/systemd/system/fwdsvc.service`:\n   ```ini\n   [Unit]\n   Description=Reticulum forwarding service (group chat)\n   After=network-online.target\n   Wants=network-online.target\n\n   [Service]\n   Type=simple\n   User=fwdsvc\n   Group=fwdsvc\n   ExecStart=/usr/local/bin/fwdsvc -config /etc/fwdsvc/config.toml\n   Restart=on-failure\n   RestartSec=5\n   # Optional hardening:\n   NoNewPrivileges=true\n   ProtectSystem=strict\n   ProtectHome=true\n   ReadWritePaths=/var/lib/fwdsvc\n   PrivateTmp=true\n\n   [Install]\n   WantedBy=multi-user.target\n   ```\n5. Enable and start:\n   ```sh\n   sudo systemctl daemon-reload\n   sudo systemctl enable --now fwdsvc\n   sudo journalctl -u fwdsvc -f\n   ```\n\n### Raspberry Pi\n\nSame as Linux + systemd. Pick the right binary for your Pi:\n\n- Pi 4/5 with 64-bit OS → `fwdsvc-linux-arm64`\n- Pi 2/3 with 32-bit OS → `fwdsvc-linux-armv7`\n- Pi Zero / Pi 1 → `fwdsvc-linux-armv6`\n\nIf you have a real LoRa modem (RNode, etc.), run upstream\n[`rnsd`](https://github.com/markqvist/Reticulum) alongside `fwdsvc`\nwith the radio attached, and point `fwdsvc` at `rnsd` via\n`tcp_client → 127.0.0.1:4242`. `fwdsvc` doesn't speak serial / LoRa\ndirectly — `rnsd` is the radio half.\n\n### Windows\n\n`fwdsvc-windows-amd64.exe` runs the same way:\n\n```powershell\n.\\fwdsvc-windows-amd64.exe -config \"$env:USERPROFILE\\.fwdsvc\\config.toml\"\n```\n\nFor unattended startup, register it with **Task Scheduler** to run on\nboot as a specific user (Action = \"Start a program\", Program =\n`fwdsvc-windows-amd64.exe`, Arguments = `-config ...\\config.toml`,\nTrigger = \"At startup\", Settings = \"Restart on failure\"). Or use NSSM\nto install it as a Windows service if you prefer that workflow.\n\n### macOS\n\nEither a launchd `plist` in `~/Library/LaunchAgents/` or just running\nunder a `tmux`/`screen` session. The binary is the same Mach-O\nuniversal-ish format; allow it through Gatekeeper the first time:\n`xattr -d com.apple.quarantine fwdsvc-darwin-arm64`.\n\n### Identity backup\n\nIf you redeploy to a different machine and only carry `config.toml`\nover, you'd lose the service's identity — and therefore its\ndestination hash — without it. To make `config.toml` self-sufficient:\n\n1. Run `fwdsvc` once. It writes `~/.fwdsvc/identity.b64.txt` (mode\n   0600) on first-run identity generation. Same secrecy class as the\n   identity file itself.\n2. Open that file and copy the base64 string.\n3. In `config.toml` under `[service]`:\n   ```toml\n   identity_b64 = \"\u003cpaste here\u003e\"\n   ```\n4. Restart. The log will read:\n   ```\n   identity loaded from config (identity_b64); ignoring …/identity\n   ```\n\nAfter that, `config.toml` alone is enough to restore the service on\nany machine — same identity, same destination hash, same chat for\nevery existing member.\n\n---\n\n## Operations\n\n### State on disk\n\nEverything lives in the directory whose paths you configured in\n`[service]` (default `~/.fwdsvc/`):\n\n| File                | Purpose | Backup-worthy? |\n|---------------------|---------|---|\n| `config.toml`       | Your config. Optionally embeds the identity. | **Yes** |\n| `identity`          | Service identity (64 bytes). Lose it → lose your destination hash. | **Yes** (or use `identity_b64`) |\n| `identity.b64.txt`  | Base64 form of `identity`, written on first-run for backup. | Yes |\n| `state.json`        | Roster + banlist. Atomic writes. | Yes |\n| `history.json`      | Replay ring buffer. | Optional (loss only affects replay-on-join) |\n| `outbound.json`     | Pending outbound retries. | Optional (loss costs at most a few queued messages) |\n| `announces.json`    | Cached peer paths. | No (regenerates from inbound announces) |\n| `fwdsvc.log`        | If `log_path` is set, the rolling daemon log. | No |\n\n### Logs\n\nWithout `log_path`, everything goes to stdout — let systemd journal\nor your terminal absorb it. With `log_path`, both stdout and the\nfile get the same lines. Each log line is `RFC3339`-ish timestamped\nto the microsecond. Examples worth recognising:\n\n| Pattern | Meaning |\n|---|---|\n| `interface tcp_client connected: …`         | TCP interface up. |\n| `announce verified (new\\|returning): …`     | We learned a path to a peer. |\n| `cmd from=\u003chash\u003e name=/\u003ccmd\u003e`               | A command arrived. |\n| `cmd reply queued: …`                       | The reply went onto the outbound queue. |\n| `outbound: attempt N/5 to \u003chash\u003e failed: …` | A delivery attempt failed; retrying. |\n| `outbound: failing message id=… after 5 attempts: …` | Gave up after the retry budget. |\n| `resource sender: ADV retry N/4 for \u003chash\u003e` | Resource transfer's ADV phase is retrying because the receiver hasn't requested any parts yet. |\n| `nick from announce: adopted \"X\" for …`     | Auto-defaulted a nickname from an inbound announce (v1.3.5+). |\n| `tcp interface … disconnected: … — reconnecting` | Upstream TCP drop; supervisor will redial with backoff (v1.3.6+). |\n| `tcp interface … reconnected`               | Reconnect succeeded; interface is live again (v1.3.6+). |\n\n### Scaling and resource use\n\nA few load-related facts worth knowing before you grow the roster past\na few dozen — none are currently a problem at typical sizes but they\nshape what you'd notice first if you pushed harder:\n\n- **Outbound queue depth scales linearly with active roster.** Each\n  inbound chat message produces one `outbound.json` entry per active\n  (non-paused) recipient. A 60-member roster + one message in flight =\n  up to ~59 pending entries. They drain promptly when recipients are\n  reachable; an unreachable recipient stays queued for up to\n  `5 × 10s ≈ 50s` before the queue gives up.\n- **Drain concurrency is fixed at 4 workers**, not scaled with roster\n  size (`outboundWorkers` constant in `internal/service/outbound.go`).\n  Four is enough that a slow send to one recipient doesn't head-of-line\n  block the others. For very large rosters (hundreds of members) on a\n  fast interface, raising the constant and rebuilding would speed up\n  fan-out.\n- **No upper bound on pending depth.** Nothing rejects new messages\n  when the queue is long. In steady state the queue is bounded by chat\n  cadence × the per-recipient retry window, not by anything explicit.\n  Worth knowing if you ever script a flood through the relay.\n- **`outbound.json` is rewritten on every `Enqueue`** — fanning out\n  one message to N recipients does N full-file writes, each marshaling\n  up to N entries (O(N²) in disk write volume per fan-out). Negligible\n  at current sizes; the first thing that would need a batched-persist\n  refactor if the roster grew toward several hundred.\n- **Attachments are not persisted.** Per-message LXMF fields (e.g. a\n  forwarded `FIELD_IMAGE`) live in memory only — a crash between\n  enqueue and send drops the image but keeps the text body, which\n  re-sends on restart. Acceptable degradation; sender can always\n  resend.\n- **History buffer is bounded** by `replay.count` (default 100). Older\n  forwarded lines roll off when a new one is appended.\n\n### Troubleshooting\n\n**My users never see replies to `/users` (or other commands).** A\nslow or stale path causes Link / Resource sends to time out. Try\n`/announce` from an admin to push a fresh announce of the daemon\ninto the mesh, and have the affected user re-announce from their\nclient. Use `/path \u003cuser\u003e` (admin) to see what the daemon knows\nabout reaching them — if `LinkActive=false` and the announce is\nmany hours old, the path is the problem, not `fwdsvc`. Restarting\nthe affected user's client usually re-announces it immediately.\n\n**My users never see ANY messages from me.** Verify the daemon is\nactually reaching the network: look for `interface tcp_client\nconnected` at startup, and `announce verified` lines (which mean\ninbound traffic is flowing). If neither shows up after 30s, the\nconfigured `[[interfaces]]` address isn't reachable from this host.\n\n**`/join` worked but nothing forwards.** Check the sender isn't\npaused (`/users` would show `[paused]`). Check the daemon log — if\na forward fails 5/5 times for a recipient, you'll see one\n`failing message id=…` line and that recipient missed that message,\nbut the chat continues for everyone else.\n\n**The destination hash changed after a redeploy.** You lost the\nidentity. Either restore the `identity` file or, better, set\n`identity_b64` in `config.toml` so this can never happen again.\n\n**Path table looks stale.** `rm ~/.fwdsvc/announces.json` and\nrestart — the cache will rebuild from live announces (cost: one\n`announce_interval` of waiting for path discovery).\n\n### Upgrading\n\n1. Stop the service.\n2. Replace the binary.\n3. Start the service.\n\nThe on-disk state format is stable; new versions read older\n`state.json`, `history.json`, `outbound.json`, `announces.json`\nfiles. If a future release ever breaks compatibility it'll be called\nout in the release notes.\n\n### Removing an admin or mod\n\nThere is no `/promote` or `/demote` runtime command — admin/mod\nmembership is config-only by design (auditable via `git diff`). Edit\n`admins` or `mods` in `config.toml`, restart.\n\n---\n\n## Wire-format support\n\nBelow are the parts of the Reticulum / LXMF stack `fwdsvc` actually\nspeaks. Each one has at least one of: a static byte-level test\nvector against canonical Python output, a passing live subprocess\ninterop test against `rns 1.2.0` + `LXMF 0.9.6`, or confirmed live\nround-trip with a third-party LXMF client.\n\n- **Identity** — X25519 + Ed25519 keypair, on-disk format, identity\n  and destination hash derivation (SPEC §1).\n- **Token cipher** — AES-256-CBC + HMAC-SHA256 + HKDF-SHA256 with the\n  `identity_hash` salt gotcha (SPEC §3).\n- **Packet header** — HEADER_1 / HEADER_2 codec including the\n  hashable-part rule that makes proofs survive HEADER_1↔HEADER_2 in\n  flight (SPEC §2).\n- **HDLC framing** — for `tcp_client` interfaces (SPEC §8.2).\n- **Announce** — build, parse, verify (with and without ratchet),\n  including the SPEC §9.3 msgpack `bin`-vs-`str` gotcha for\n  `app_data`.\n- **Opportunistic LXMF** — full sign / encrypt / decrypt / verify in\n  both directions, including SPEC §5.6 dual-msgpack-variant tolerance.\n- **PROOF emission** (SPEC §6.5) — every inbound CTX_NONE DATA at a\n  SINGLE destination is acknowledged with a 64-byte implicit-form\n  proof so senders' `PacketReceipt`s resolve.\n- **Path requests** (SPEC §7.1) — when a sender we can't verify\n  contacts us, we issue a `path?` broadcast; a path-aware relay's\n  path-response announce gives us their public key. Per-target 60 s\n  dedup window with periodic sweep.\n- **HEADER_2 originator conversion** (SPEC §2.3) — outbound DATA to a\n  multi-hop recipient is HEADER_2 with the cached next-hop\n  `transport_id`.\n- **Reticulum Link** (SPEC §6) — full LINKREQUEST / LRPROOF handshake\n  (byte-exact against the spec test vector), ECDH+HKDF session keys,\n  link-form Token cipher, link-DATA framing, SPEC §6.5.6 explicit-form\n  96-byte link PROOFs. Idle links auto-close after 15 min;\n  KEEPALIVE every 4 min.\n- **Resource transfer** (SPEC §10) — full sender and receiver. Send:\n  link-encrypt the body, slice into raw-ciphertext parts, advertise\n  via msgpack ADV, fulfill receiver-driven REQs, validate the\n  receiver's RESOURCE_PRF in constant time. Receive: parse ADV,\n  fetch parts, verify, decrypt. Up to 256 KiB / 74 parts per\n  resource; inbound `c=1` (bz2-compressed) and `n\u003e74` ADVs are\n  rejected (bomb defense — see\n  [`docs/resource-security-audit.md`](docs/resource-security-audit.md)).\n\nDelivery-path selection is automatic: ≤ ~280 bytes is opportunistic,\n≤ 431 bytes plaintext over a Reticulum Link is single-packet Link\nDATA, anything bigger is Resource transfer over that same Link. So\nlong `/users` replies on a big roster ship the full list — size is\nnot a delivery constraint.\n\n---\n\n## Reactions: what a client must implement\n\nReactions ride on `FIELD_REACTION` (`0x40`, LXMF 1.0.0). Because `fwdsvc`\nre-originates every message (re-signs it as the service), a client needs\nthe four things below for reactions to work *through the relay*. Full\nwire detail, rationale, and the cross-client convention are in\n[`docs/reaction-attribution.md`](docs/reaction-attribution.md) — written\nso Sideband / MeshChatX / Columba / ratspeak (or any LXMF client) can\nimplement against it.\n\n1. **Decode nested integer-keyed field maps.** A reaction's fields are\n   `{0x40: {0x00: \u003craw 32-byte target message_id\u003e, 0x01: \u003cUTF-8 content\u003e}}`\n   — the inner dict has **integer** keys. Many msgpack libraries decode\n   a nested map *value* into a string-keyed map and reject integer keys;\n   Go's `vmihailenco/msgpack` fails with `invalid code=0 decoding\n   string/bytes length` and **drops the entire message**. Decode into an\n   untyped / any-keyed map at *every* nesting level (Go:\n   `Decoder.SetMapDecoder(func(d){ return d.DecodeUntypedMap() })`), and\n   tolerate any integer key width (`int8`/`int64`/…) and `bytes`-or-`str`\n   values. The same applies to `FIELD_COMMENT` (`0x41`) and\n   `FIELD_CONTINUATION` (`0x42`). **This is the #1 cause of \"reactions\n   silently don't work\"** — the tell is that replies (`0x30`, raw bytes\n   at the top level) work but reactions vanish, and over a Link the\n   sender retries forever (it never gets a delivery proof).\n\n2. **Emit reactions on `0x40`** with empty `content`/`title`: `0x00` =\n   the raw 32-byte `message_id` of the target *as your client received\n   it*, `0x01` = the reaction text (e.g. an emoji). Do **not** use the\n   pre-1.0.0 `fields[16]` shape — `fwdsvc` no longer accepts it.\n\n3. **Send the reaction to the relay.** No special routing needed: the\n   relayed message's `source_hash` *is* the relay (we re-sign), so a\n   reaction addressed to the message you're reacting to naturally goes\n   back to the relay for fan-out.\n\n4. **Attribute by the originator stamp.** So reactions aren't all\n   attributed to the relay, `fwdsvc` stamps the reactor's `source_hash`\n   into custom fields. When a received reaction carries\n   `fields[0xFB] == \"originator-identity\"` (exact UTF-8) and a\n   **well-formed** `fields[0xFC]` (the reactor's raw **16-byte\n   `source_hash`** — its `lxmf.delivery` destination hash, the same value\n   a direct reaction carries and what contacts are keyed by, per SPEC\n   §5.9.8 / §9.1; **not** the identity hash, which would orphan the\n   lookup), attribute / aggregate by that hash instead of the carrying\n   `source_hash`; fall back to `source_hash` when the stamp is absent.\n   ⚠️ The stamp is **unauthenticated** — honor it **only** when the\n   reaction arrived via a **trusted relay** (e.g. the source that\n   delivered the reacted-to message), and validate `0xFC` is 16 bytes,\n   else a direct peer can forge attribution to anyone. Aggregate by\n   `(reactor-identity, reaction-content)`. Full convention + the trust\n   rules: [`docs/reaction-attribution.md`](docs/reaction-attribution.md).\n\nTarget binding \"just works\": the relay rewrites the reaction's target\n`message_id` per recipient (each member computed a different id for the\nsame bubble), so you react with the id your client holds and it lands on\nthe right message everywhere.\n\n---\n\n## Limitations\n\nThe implementation is intentionally minimal — just enough Reticulum +\nLXMF to run a group-chat hub. Notable gaps:\n\n- **Single TCP interface type** — `tcp_client` only. No LoRa /\n  RNode-serial, no UDP, no AutoInterface (LAN multicast), no I2P. A\n  Pi with a real LoRa modem will need to run upstream `rnsd`\n  alongside `fwdsvc` and point `fwdsvc` at `rnsd` over TCP.\n- **No transit relay.** `fwdsvc` is a leaf node — it doesn't forward\n  third-party packets.\n- **No automatic TCP reconnect.** If the configured `tcp_client`\n  interface drops, the service logs and continues; you have to\n  restart it. Use systemd `Restart=on-failure` (already in the\n  recipe above).\n- **No ratchets / forward secrecy.** Long-term X25519 key is used\n  for every Token cipher. Future-key compromise means past messages\n  are decryptable.\n- **No stamps / proof-of-work anti-spam.** Peers that *require*\n  stamps will silently reject our outbound LXMF.\n- **Limited LXMF field support.** `FIELD_IMAGE` (6) and the upstream\n  LXMF 1.0.0 message-meta fields — reply-to (`0x30`=48 + `0x31`=49),\n  reactions (`FIELD_REACTION 0x40`=64), comments (`0x41`=65), and\n  continuations (`0x42`=66) — are forwarded through group chat by\n  default; `FIELD_FILE_ATTACHMENTS` (5) and `FIELD_AUDIO` (7) can\n  be enabled per-operator via `forwarded_fields`. Stickers, embedded\n  LXMs, telemetry, icon-appearance, and command fields are still\n  parsed but discarded.\n- **Reactions / reply-to lifetime.** Because the relay re-emits each\n  forwarded message under its own identity, every recipient computes\n  a different `message_id` for the same bubble — so cross-client\n  reactions and replies need per-recipient rewriting. fwdsvc does\n  this in-memory via a TTL cache (`id_cache_ttl`, default 24h), so\n  reactions / replies to messages relayed within that window bind\n  correctly on every other member's client. Past the TTL (or after a\n  service restart, since the cache is not persisted) the binding\n  falls back to legacy behavior — reactions don't render, replies\n  show only their `fields[0x31]` quote preview.\n- **Reaction attribution.** Because the relay re-signs each reaction as\n  itself, a reaction's `source_hash` becomes fwdsvc — and a reaction\n  has no body to carry a `[Nick]` prefix, so naive relaying collapses\n  every reaction onto the service. fwdsvc stamps the original reactor's\n  `source_hash` (its destination hash, what contacts are keyed by — SPEC\n  §9.1) into the upstream custom fields\n  (`FIELD_CUSTOM_TYPE 0xFB = \"originator-identity\"`, `FIELD_CUSTOM_DATA\n  0xFC = reactor source_hash`) so cooperating clients attribute the\n  reaction to the reactor, not the relay. The `0x40` wire format is\n  untouched and the stamp is purely additive (spec-only clients ignore\n  it). This is an app-layer interop convention documented for other\n  clients in [`docs/reaction-attribution.md`](docs/reaction-attribution.md).\n  Replies need no stamp — they carry a body and ride the `[Nick]` path.\n- **No voice / audio.** Text-only. We register only the\n  `lxmf.delivery` aspect — never `call.audio`. Some MeshChat users\n  see a brief \"incoming call\" notification attributed to `fwdsvc`\n  shortly after our announce. Audit on our side ruled out every\n  code path that could possibly target a `call.audio` dest_hash;\n  suspected upstream cause + diagnostic ask in\n  [`docs/meshchat-call-codec-mismatch-issue.md`](docs/meshchat-call-codec-mismatch-issue.md).\n  Not a `fwdsvc` bug.\n- **IFAC packets rejected.** Packets with the IFAC flag set are\n  refused at parse with `errIFACUnsupported`. Real IFAC support\n  would require new interface config and is not on the roadmap.\n\n---\n\n## Build from source\n\nRequires Go 1.26 or newer.\n\n```sh\ngit clone https://github.com/thatSFguy/reticulum-group-chat\ncd reticulum-group-chat\ngo mod tidy\ngo build -o fwdsvc ./cmd/fwdsvc\ngo test ./...\n```\n\nCross-compile every release target into `build/`:\n\n```sh\n./scripts/build-all.sh\nls -lh build/\n```\n\n### Verification\n\nThree increasingly strong levels of test:\n\n```sh\n# 1. Default unit + spec test vectors. Static byte-level equality\n#    against canonical Python rns 1.2.0 / LXMF 0.9.6 vectors loaded\n#    from ../reticulum-specifications/test-vectors/ (skipped cleanly\n#    if the spec sibling repo isn't checked out).\ngo test ./...\n\n# 2. Live Python subprocess interop. Spawns a Python helper that\n#    drives upstream rns + LXMF directly and exchanges fresh\n#    announce + opportunistic-LXMF bytes with the Go code in BOTH\n#    directions. Requires `pip install rns lxmf` and `python` on\n#    PATH. Skipped otherwise. Also runs on CI on every push.\ngo test -tags=interop ./tests/interop/...\n```\n\nPlus a **live mesh interop check** during development: the service\nruns against a community-run testnet entry node\n(`rns.chicagonomad.net`, `rns.michmesh.net`) and is exercised\nend-to-end with Sideband / NomadNet / MeshChat — announce\npropagation, opportunistic LXMF send, PROOF emission, path-request\nresolving an unannounced sender, Link + Resource delivery,\nround-tripping back to the mobile UI.\n\n---\n\n## Project info\n\n### License\n\nMIT.\n\n### Contributing\n\nThis implementation tracks\n[the canonical Reticulum / LXMF spec](https://github.com/thatSFguy/reticulum-specifications)\ndirectly. Wire-format changes should reference the relevant SPEC.md\nsection number in the commit message and either include a static test\nvector or pass live interop.\n\nIssues that find a discrepancy between this implementation and\nupstream Python `rns` / `LXMF`: please cite the upstream\n`file:line` and a runtime reproduction in the report.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthatsfguy%2Freticulum-group-chat","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fthatsfguy%2Freticulum-group-chat","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthatsfguy%2Freticulum-group-chat/lists"}