Parley: Federated, decentralised chat that speaks plain IRC
With several people sharing a nick across instances, a channel mention of one of them pushes a notification to all of them. ## Cause `namesAccount` counted a name as a mention wherever the nick stood on its own, and a colon ends a word. So with a `bob` on foo.com and another on bar.com in one room: - `bob:bar.com: lunch?` typed on foo.com woke both bobs, not just bar's. - A bare `bob: lunch?` from bar.com, which names bar's bob, also woke foo's. Message text crosses the federation as typed, so the same line reaches every instance with a bob in the room, and every one of them pushed. ## Change - A name is read the way the sender was shown it (R189): - `bob:bar.com`, like the address `bob@bar.com`, is bob on bar.com whoever wrote it. - A bare `bob` is the bob on the sender's own instance, so it names an account here only when the sender is local. - `bar` inside `alice:bar.com` or `alice@bar.com` names nobody. - Only this instance's domain or a peer's qualifies a name. That covers every nick a client can have been shown, and keeps `bob:D` and `bob:10` meaning bob. `pushConsidered` reads `s.peers` for this, under the `s.mu` its callers already hold. - `TestOnlyTheBobThatWasMeantIsPushed` links two instances with a bob on each and checks that each of five lines pushes only to the bob it names. On `main` it fails on the first: ``` a notification was pushed to /bar.com/bob and should not have been ``` - `TestANameMeansWhoTheSenderWasShown` covers the reading case by case. `TestNamingSomebodyIsNotMerelyContainingTheirName` is unchanged apart from passing a local sender. - R189 plus a `NAMES [read-as-the-sender-was-shown-them]` node under `PUSH [considered]` in `parley.bt`, and a paragraph in `docs/PROTOCOL.md` next to the one on bots matching both spellings of their nick. One behaviour changes on purpose: a line from another instance that names somebody here only by a bare nick no longer pushes to them. From the sender's side it named someone else, and their client offered them the qualified nick. ## Checked - `make test` passes, with `-race`, and the new integration test passed 10 times in a row. - `bte check parley.bt` reports 0 errors and the same 7 warnings as on `main`, and `bte fmt` leaves the file unchanged. - `demo/demo.sh` has not been run; Docker is not available here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: David Collantes
Reviewed-on: #39 README.md Parley Federated, decentralised chat that speaks plain IRC. Parley is a chat network with no centre. Every person (or team) runs a small instance for their own domain. Instances find each other through DNS and well-known identity documents, exchange signed messages over HTTPS, and present the whole federated network to ordinary IRC clients such as irssi, WeeChat or Textual, with no plugins. Identities look like email: alice@foo.com runs on foo.com, bob@bar.com on bar.com. Bob types /msg alice@foo.com hi and it just works, even if the two instances have never heard of each other before. Status: working proof of concept. It demonstrates the design end to end and runs a real instance, but it is not hardened yet. See Limitations. Status: working proof of concept. It demonstrates the design end to end and runs a real instance, but it is not hardened yet. See Limitations. It works with the client you already have Two instances (foo.com and bar.com), two stock irssi sessions. Alice connects to her instance; Bob connects to his: Bob opens a query with /msg alice@foo.com and the two instances federate on the spot. Alice sees Bob as bob on bar.com; Bob sees Alice as alice on foo.com: Both join #lobby, a global channel replicated across their instances: What it does Ordinary IRC in front. Connect with any IRC client. Log in with PASS or SASL PLAIN using your password or an IRC token. IRCv3 server-time, message-tags, echo-message, multi-prefix and setname are offered to clients that want them, and TAGMSG relays client-only tags such as typing indicators, across the federation as well as between clients here. Tags on a message itself are kept, so a +reply is still a reply when it comes back out of history, and draft/multiline makes a pasted paragraph one message rather than eight. Scrollback that follows you rather than your client. CHATHISTORY pages through channels and private messages alike, a join replays what you have not been shown, and draft/read-marker puts where you have read up to on the account, so marking a channel read on your phone clears it on your desktop and a client is told where to draw the line as soon as it joins. Accounts you can manage while it runs. Accounts live in the instance's data directory, not in a config file. Create them with parleyctl, the admin page, the HTTP API, or let people arrive through single sign-on: any OpenID Connect provider, or identity headers from a reverse proxy. People mint IRC tokens for their clients on their settings page; bots are accounts with the bot role. Discovery via DNS + WKID. _parley._tcp.
SRV points at the instance; https://
/.well-known/parley/instance.json publishes its ed25519 public key and inbox; /.well-known/parley/
.json confirms a user exists. This mirrors how Salty IM finds people. Signed HTTP federation. Every event is a JSON document POSTed to the peer's /inbox, with a detached ed25519 signature in headers. Receivers verify against the key they discovered themselves. Federation is open: any instance whose signature checks out can talk to you. Automatic peering. Message someone on a new domain and the two instances link up on their own. Linked instances exchange the peers they know (gossip), so a mesh forms without configuration. Two kinds of channel. #dev is global, replicated across every linked instance with members in it. Nobody owns it, so it has no topic and no operators. ¬es is local: it never leaves the instance, is invisible to peers, and is the one place a topic exists. #dev is global, replicated across every linked instance with members in it. Nobody owns it, so it has no topic and no operators. ¬es is local: it never leaves the instance, is invisible to peers, and is the one place a topic exists. Moderation without channel ownership. Nobody can be kicked out of a channel nobody owns, so /ban becomes a block list by mask: /ban quark@example.com for one person, /ban *!*@example.com for a whole instance. Yours covers your account; an admin's covers the instance. Peering itself is controlled with parleyctl peers. Addresses map onto ordinary IRC identity. A user's nick is the bare local part and their instance is the host, so alice appears as alice!alice@foo.com -- prefixes, NAMES, WHO and WHOIS all agree, and a nick you see in NAMES is one you can message. /whois alice@foo.com consults her instance's WKID document. History that survives downtime, and is searchable. Each instance keeps its channel history in SQLite with a full-text index (parleyctl search) and serves it at /channels/
/feed. When a peer comes back after an outage it pulls what it missed, and clients get recent history replayed on JOIN. Quick start (two instances on one machine, no DNS) git clone https://git.mills.io/prologic/parley.git && cd parley make build # Alice's instance for foo.com ./parleyd -domain foo.com -irc 127.0.0.1:6667 -http 127.0.0.1:8080 \ -endpoint http://127.0.0.1:8080 -data ./data/foo -admin-token foo-admin \ -insecure -resolve bar.com=http://127.0.0.1:8081 # Bob's instance for bar.com ./parleyd -domain bar.com -irc 127.0.0.1:6668 -http 127.0.0.1:8081 \ -endpoint http://127.0.0.1:8081 -data ./data/bar -admin-token bar-admin \ -insecure -resolve foo.com=http://127.0.0.1:8080 # One account on each ./parleyctl -endpoint http://127.0.0.1:8080 -token foo-admin accounts create alice -password secret ./parleyctl -endpoint http://127.0.0.1:8081 -token bar-admin accounts create bob -password secret Then, in two terminals: irssi -c 127.0.0.1 -p 6667 -n alice -w secret irssi -c 127.0.0.1 -p 6668 -n bob -w secret /join #dev # both /msg #dev hello # bob sees "
hello", host foo.com /msg alice@foo.com psst # direct message across instances /join ¬es # local to this instance, never federated /whois alice@foo.com -insecure and -resolve exist only for local development. In production each instance has a real domain, a TLS certificate, an SRV record, and finds peers via DNS. The real thing: DNS + TLS demo demo/ brings up CoreDNS (authoritative for foo.com and bar.com, with _parley._tcp SRV records), a local demo CA, and both instances in Docker, then runs scripted IRC sessions and prints the evidence: make demo # or: demo/demo.sh make demo-down See demo/README.md. Running your own instance The container image is prologic/parley, and docker-compose.example.yml is a starting point. An instance for example.com, reachable at chat.example.com, needs: A place to run it, with /data on persistent storage (it holds the instance key, the peer cache, the channel logs and the accounts): docker run -d --name parley -v parley-data:/data \ -p 6667:6667 -p 8443:8443 \ -e PARLEY_ADMIN_TOKEN="s3cret" \ prologic/parley -domain example.com -endpoint https://chat.example.com Then create accounts with parleyctl (it is in the image too) or the admin page: docker exec parley parleyctl -token s3cret accounts create alice -password hunter2 docker exec parley parleyctl -token s3cret tokens create alice -label irssi Without an admin token and with no accounts yet, parleyd logs a one-time /setup URL that creates the first admin in the browser. Single sign-on through OpenID Connect or a reverse proxy's identity headers is described in docs/AUTH.md; SSO users mint IRC tokens for their clients on their settings page. A place to run it, with /data on persistent storage (it holds the instance key, the peer cache, the channel logs and the accounts): docker run -d --name parley -v parley-data:/data \ -p 6667:6667 -p 8443:8443 \ -e PARLEY_ADMIN_TOKEN="s3cret" \ prologic/parley -domain example.com -endpoint https://chat.example.com Then create accounts with parleyctl (it is in the image too) or the admin page: docker exec parley parleyctl -token s3cret accounts create alice -password hunter2 docker exec parley parleyctl -token s3cret tokens create alice -label irssi Without an admin token and with no accounts yet, parleyd logs a one-time /setup URL that creates the first admin in the browser. Single sign-on through OpenID Connect or a reverse proxy's identity headers is described in docs/AUTH.md; SSO users mint IRC tokens for their clients on their settings page. HTTPS in front of port 8443 on chat.example.com. Any reverse proxy that terminates TLS will do; parleyd itself serves plain HTTP unless you give it -tls-cert and -tls-key. HTTPS in front of port 8443 on chat.example.com. Any reverse proxy that terminates TLS will do; parleyd itself serves plain HTTP unless you give it -tls-cert and -tls-key. An SRV record so other instances can find you: _parley._tcp.example.com. IN SRV 0 0 443 chat.example.com. Without it, peers fall back to https://example.com/.well-known/parley/. An SRV record so other instances can find you: _parley._tcp.example.com. IN SRV 0 0 443 chat.example.com. Without it, peers fall back to https://example.com/.well-known/parley/. IRC over TLS for your clients. parleyd's IRC listener is plaintext, so terminate TLS in front of it. With Caddy's layer4 module, for example: layer4 { 0.0.0.0:6697 { @parley tls sni chat.example.com route @parley { tls proxy 10.1.2.3:6667 } } } Then, in irssi: /connect -tls -tls_verify chat.example.com 6697
alice. The port here is whatever your proxy listens on. If it is not 6697, start parleyd with -irc-port (and -irc-host, if IRC is on a different name to the endpoint), or set PARLEY_IRC_PORT / PARLEY_IRC_HOST: parleyd -domain example.com -irc-port 6687 The landing page, the settings page, parleyctl and the instance document all print a connect line from it, and the settings page prints a live token on that line. A wrong port under a right hostname still passes certificate verification, so the token would go to whatever else is listening there. IRC over TLS for your clients. parleyd's IRC listener is plaintext, so terminate TLS in front of it. With Caddy's layer4 module, for example: layer4 { 0.0.0.0:6697 { @parley tls sni chat.example.com route @parley { tls proxy 10.1.2.3:6667 } } } Then, in irssi: /connect -tls -tls_verify chat.example.com 6697
alice. The port here is whatever your proxy listens on. If it is not 6697, start parleyd with -irc-port (and -irc-host, if IRC is on a different name to the endpoint), or set PARLEY_IRC_PORT / PARLEY_IRC_HOST: parleyd -domain example.com -irc-port 6687 The landing page, the settings page, parleyctl and the instance document all print a connect line from it, and the settings page prints a live token on that line. A wrong port under a right hostname still passes certificate verification, so the token would go to whatever else is listening there. Check it from the outside. parleyctl check probes an instance the way a peer does — SRV record, well-known documents, the advertised endpoint, the inbox, and the IRC TLS port — and says what to fix: $ parleyctl check alice@example.com ok dns _parley._tcp.example.com -> chat.example.com:443 ok well-known https://chat.example.com/.well-known/parley/instance.json (parleyd/v0.2.0) ok domain example.com ok key ed25519 uPMn03bl2/qHh9X0B3Fn98yiWo7VS6Z9UHXJ7P8bO2E= ok endpoint https://chat.example.com is live ok inbox https://chat.example.com/inbox rejects unsigned events ok user alice@example.com exists (account) ok irc-tls chat.example.com:6697: chat.example.com, issued by E7, 89 days left It needs no token and works against anyone's instance, so it is also how you tell a peer what is wrong with theirs. The common failure is a missing SRV record: the instance is perfectly reachable at its own host, but nobody resolving the identity domain can find it. Add -resolve https://chat.example.com to probe the host directly while DNS is still wrong, and -json for a machine-readable report. It exits non-zero if any check fails. Check it from the outside. parleyctl check probes an instance the way a peer does — SRV record, well-known documents, the advertised endpoint, the inbox, and the IRC TLS port — and says what to fix: $ parleyctl check alice@example.com ok dns _parley._tcp.example.com -> chat.example.com:443 ok well-known https://chat.example.com/.well-known/parley/instance.json (parleyd/v0.2.0) ok domain example.com ok key ed25519 uPMn03bl2/qHh9X0B3Fn98yiWo7VS6Z9UHXJ7P8bO2E= ok endpoint https://chat.example.com is live ok inbox https://chat.example.com/inbox rejects unsigned events ok user alice@example.com exists (account) ok irc-tls chat.example.com:6697: chat.example.com, issued by E7, 89 days left It needs no token and works against anyone's instance, so it is also how you tell a peer what is wrong with theirs. The common failure is a missing SRV record: the instance is perfectly reachable at its own host, but nobody resolving the identity domain can find it. Add -resolve https://chat.example.com to probe the host directly while DNS is still wrong, and -json for a machine-readable report. It exits non-zero if any check fails. How it fits together irssi ──IRC──▶ parleyd (foo.com) ◀──signed HTTPS──▶ parleyd (bar.com) ◀──IRC── irssi │ /.well-known/parley/*.json │ │ /inbox /channels/
/feed │ └──── DNS: _parley._tcp.bar.com SRV ──────────┘ Bob's client sends PRIVMSG alice@foo.com :hi. bar.com looks up _parley._tcp.foo.com, fetches the instance document from the host it names, and caches the key. bar.com signs the event and POSTs it to foo.com's inbox. foo.com discovers bar.com the same way to verify the signature, delivers the message to Alice's clients, and since bar.com is a stranger, sends a hello back. Both sides now exchange channel rosters and peer lists. The wire format is documented in docs/PROTOCOL.md. Configuration There is no config file. Configuration is in two places and each thing is in exactly one of them. Flags, each with an environment variable, for what the process needs before it can open its database, what describes the machine and network it sits on, and the secrets and trust decisions about who may assert an identity. A flag wins over its variable. Only -domain is required. Settings, in the database, for everything an administrator might change while the instance runs. They take effect the moment they are saved, with no restart, and have no flag and no environment variable. Change them on the admin page, through PUT /api/v1/settings (docs/API.md), or with parleyctl: parleyctl settings list # every setting, * where it differs from its default parleyctl settings set history_replay 50 max_conns_per_addr 8 parleyctl settings reset motd # back to the default parleyctl settings export > settings.json parleyctl settings import settings.json # also reads an old config.json Only what differs from the defaults is stored, so an upgrade that changes a default changes it for every instance that never touched that key. People set their own picture under Settings in the web interface, or it comes from their identity provider's picture claim at login and is re-hosted here. It is published to IRC clients as the IRCv3 avatar metadata key and to other instances in the well-known user document; see docs/PROTOCOL.md. The instance's logo is not in this table, because it is not text: upload one PNG of at least 512 pixels along its longest side under Settings -> Logo in the admin page and Parley derives the favicon, the home-screen icon and the square an IRC client shows beside the network. A square is ideal, and a wordmark is fine -- anything up to three times as long as it is tall is centred on a transparent square rather than refused. Until then every instance shows Parley's own icon, which is why two of them look alike in a client's network list. Chat help. /help (or /quote HELP
) serves the pages in help/*.txt, which are compiled into the binary. Edit a page and rebuild to change it; the first line is its title. A new file is a new topic -- list it in help/index.txt, which make test checks. Endpoints: /healthz for liveness, /api/v1/status for a JSON status of peers and channels, /metrics for Prometheus, and the API in docs/API.md. Upgrading from a config file. parleyd -config config.json no longer reads the file: it prints, for every key in it, the flag, variable or setting that key has become, and exits. Move the process-level keys to your unit file or compose file, start the instance, then parleyctl settings import config.json applies the rest in one go and names what it skipped. Real client addresses behind a TCP proxy With TLS terminated in front of the IRC listener, every client arrives from the proxy: the logs name the wrong host, and per-address limits either do nothing or lock everybody out at once. List the proxy in irc_proxies and it must then prepend a PROXY protocol header (v1 or v2) to each connection. This is two changes, and neither works alone. Listing a proxy that does not send a header drops every connection from it; sending a header from an address that is not listed feeds it to the IRC parser as garbage. There is no safe order, so change both together and be ready to put both back. The default is empty, which is the whole thing switched off. 127.0.0.1/32 below is an example, not a default -- substitute the address your own connections actually arrive from: parleyd -irc-proxy 127.0.0.1/32 # repeatable PARLEY_IRC_PROXIES=127.0.0.1/32 # comma- or space-separated Note the name: PARLEY_TRUSTED_PROXIES is the web listener's equivalent and a different decision entirely. To find the address to list, connect once and read the log. Every client address is reported the first time it is seen: level=INFO msg="irc: new client address" addr=203.0.113.9 trusted_proxy=false Behind a proxy every client shares one address, so that is a single line naming exactly what belongs in irc_proxies. It is always the address on the socket, never the one a header carries -- the header address could never match the list, so reporting it would hand you a value guaranteed to fail. Once the proxy is listed and sending headers, the line reappears for it with trusted_proxy=true, which is the confirmation that the two halves now agree. Only listed addresses are believed, and a connection from one of them that does not carry a header is dropped rather than treated as a direct connection -- accepting both shapes from the same place hands back the address forgery the header exists to stop. Those drops are counted in parley_irc_proxy_rejected_total, which is the metric to watch while making the change. The web listener has the same blindness and its own setting. Behind a reverse proxy every request arrives from that proxy, so the per-address gates on web login, the federation inbox and feed reads all share one bucket -- a global rate limit wearing a per-address name. List the proxy in http_proxies and X-Forwarded-For is believed from it, and nothing else changes. Use that rather than auth.trusted_headers.proxies, which looks like it would do the job and does far more: a proxy on that list may assert who the user is, so a request to /login carrying Remote-User is logged in without a password. Fixing a rate limit is no reason to turn on passwordless login. The identity list does imply the address one, since a proxy trusted that far is not one to doubt about an address. The step-by-step version, including the order that costs one outage instead of two, is in docs/PROXY.md. With real addresses in hand, max_conns_per_addr becomes safe to turn on, and turning it on also applies the per-address login bucket to IRC. Leave it at 0 while the listener sits behind an unlisted proxy: every client shares one address there, so the cap would not limit anybody in particular, it would lock out everybody at once. It is a setting, so turning it on is parleyctl settings set max_conns_per_addr 8 and needs no restart. The per-account login backoff applies either way -- see docs/AUTH.md. Metrics /metrics serves the Prometheus text format: connections, accounts online, channels, peers linked, and counters for pushes, inbox events, feed reads, tag messages and what was refused. It needs an admin token by default, since how many people are on an instance is the operator's business; set the metrics_public setting to serve it openly. Every sample is a count -- no nicks, no channel names, no peer domains. Back up the instance key
/identity.key is the one file that cannot be regenerated. The domain's identity is the keypair: lose it and every peer that has cached the old public key refuses the instance, and the only fix is a new key that everyone has to re-trust. Back it up on the host, off the host: parleyctl key show # public key and fingerprint parleyctl key export -y > instance.key # the secret; keep it somewhere safe parleyctl key import < instance.key # restore into an empty data dir If the key ever leaks, replacing it is a supported operation rather than a disaster: parleyctl key stage # generate the next key and publish it parleyctl key promote -y # ...then, once peers have re-read it, sign with it parleyctl key rotate -now -y # a leaked key: replace it at once, no grace period Peers recover by themselves. A signature they cannot verify makes them re-read the instance document once, which is where they find the new public key, so the cost is one failed request each -- and if that request was a hello, a few minutes of backoff before they try again. The old key is kept as identity.key.
.bak because the only unrecoverable mistake here is replacing a key you still needed. Restart parleyd afterwards to serve the new one. These read and write the data directory directly rather than going through the API, because an admin token must never be able to fetch the private key over the network. Run them on the instance host, with -data-dir or PARLEY_DATA_DIR pointing at the data directory. Upgrading Nothing to run: the database gains its new tables on first start and the old ones are untouched. What follows is the handful of things that changed under you, newest first. To v0.6.0 How much one account may say is now bounded. message_rate (1 a second) and message_burst (20 at once) apply to everybody but bots, keyed by the account, so a person's clients share one budget. A refused message is answered 439 naming the target and is delivered nowhere. Every instance before this had no bound at all, so if yours has a room busier than that, raise the numbers or set message_rate to 0, which is the old behaviour: parleyctl settings set message_rate 0 It is a setting, so it lands without a restart. To v0.5.0 Somebody on another instance is alice:foo.com, not alice/foo.com. The separator was borrowed from draft/relaymsg, and it was the wrong half of the convention to copy: a strict client guards against mistaking a server name for a nick by requiring both a . and a : or neither, and a name with a domain has a dot and no colon. Such a client read the whole prefix as a server name and dropped every JOIN, PART, QUIT, NICK and AWAY from a remote user, while PRIVMSG degraded quietly -- which is why chat looked fine and no roster ever updated. PARLEYSEP in ISUPPORT says which character is in use, so a bot should read it from there rather than hardcode one. The trap while a network is mid-upgrade: mention text crosses federation as bytes. Somebody on a peer that is still on / types alice/foo.com, and it does not match anything here until they upgrade. Tell your peers rather than letting them find it. A reaction sent to a peer older than v0.5.0 is lost, not queued. Those versions answer an event type they do not implement with a 422, and a refusal is final to the sender's outbox: the event is dropped and the sender is told the send failed. From v0.5.0 on, an unimplemented type is answered 200 {"unsupported": true} instead, so this is the last change that has to wait for the whole mesh. Reactions between people here, and between upgraded instances, are unaffected. GET /api/v1/status no longer publishes channel rosters. It is the only unauthenticated endpoint, and it was naming every member of every global channel -- our peers' users included, on their behalf, when their own landing page publishes counts and never names. A channel still carries name, a members count and on, the other domains with members in it. Nothing in federation read it: peers exchange members in signed hello snapshots. history_replay now defaults to 50, up from 20. Only settings you have never set follow a default, so an instance that has chosen a number keeps it. From a version that took -user or PARLEY_USERS Those are gone. Start the new version, then import the old list once: echo "alice:s3cret,bob:hunter2" | parleyctl -token
import Project layout cmd/parleyd the daemon cmd/parleyctl command-line client for the HTTP API internal/accounts accounts, IRC tokens and web sessions (SQLite) internal/auth identity providers: OpenID Connect, trusted headers internal/config configuration internal/homecloud Home Cloud app integration (household sync) internal/identity ed25519 instance key, request signing/verification internal/discovery DNS SRV + well-known resolution with caching internal/protocol wire types: documents, events, hashing internal/store append-only channel logs, peer cache internal/server IRC front end, federation inbox/outbox, channel state, HTTP API, web UI internal/web HTML templates (HTMX + DaisyUI + Tailwind) demo/ CoreDNS + Docker Compose demonstration homecloud/ Home Cloud catalog manifest help/ the chat HELP pages, embedded at build time docs/PROTOCOL.md the federation protocol docs/API.md the HTTP API docs/AUTH.md login, single sign-on and tokens make test runs in-process end-to-end scenarios covering global and local channels, direct messages with automatic peering, peer exchange, catch-up after downtime, signature rejection and IRC basics. Limitations Known gaps, roughly in the order they should be closed: Instance-level keys only. Per-user keys and end-to-end encryption (Salty style) would layer on top. No channel modes and no channel operators, which is a decision rather than a gap: a global channel is owned by nobody, so there is nobody to be an operator of it. Blocking is per person and per instance instead — see /ban above. Nicks are bound to accounts; there is no /nick. Global channels are eventually consistent and have no topic authority. Feeds are served unauthenticated (they are public logs, like twtxt). There is no web chat client, on purpose. The web interface is for administering and configuring an instance; chatting is a client's job, and Parley is the server every IRC client already talks to. Roadmap ideas Per-user keys published via WKID, with encrypted DMs. More of IRCv3 where clients render it: message redaction, and the rest of the user metadata registry. License MIT, see LICENSE.