Parley: Federated, decentralised chat that speaks plain IRC
Article URL: https://git.mills.io/prologic/parley Comments URL: https://news.ycombinator.com/item?id=49875913 Points: 179 # Comments: 83
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.
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
PASSor SASL PLAIN using your password or an IRC token. IRCv3server-time,message-tags,echo-message,multi-prefixandsetnameare offered to clients that want them, andTAGMSGrelays 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+replyis still a reply when it comes back out of history, anddraft/multilinemakes a pasted paragraph one message rather than eight. - Scrollback that follows you rather than your client.
CHATHISTORYpages through channels and private messages alike, a join replays what you have not been shown, anddraft/read-markerputs 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 thebotrole. - Discovery via DNS + WKID.
_parley._tcp.<domain>SRV points at the instance;https://<host>/.well-known/parley/instance.jsonpublishes its ed25519 public key and inbox;/.well-known/parley/<user>.jsonconfirms 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.
#devis global, replicated across every linked instance with members in it. Nobody owns it, so it has no topic and no operators.¬esis 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
/banbecomes a block list by mask:/ban quark@example.comfor one person,/ban *!*@example.comfor a whole instance. Yours covers your account; an admin's covers the instance. Peering itself is controlled withparleyctl 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,WHOandWHOISall agree, and a nick you see inNAMESis one you can message./whois alice@foo.comconsults 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/<name>/feed. When a peer comes back after an outage it pulls what it missed, and clients get recent history replayed onJOIN.
Quick start (two instances on one machine, no DNS)
Then, in two terminals:
-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:
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
/dataon persistent storage (it holds the instance key, the peer cache, the channel logs and the accounts):Then create accounts with
parleyctl(it is in the image too) or the admin page:Without an admin token and with no accounts yet, parleyd logs a one-time
/setupURL 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-certand-tls-key. -
An SRV record so other instances can find you:
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:
Then, in irssi:
/connect -tls -tls_verify chat.example.com 6697 <password-or-token> alice.The port here is whatever your proxy listens on. If it is not 6697, start
parleydwith-irc-port(and-irc-host, if IRC is on a different name to the endpoint), or setPARLEY_IRC_PORT/PARLEY_IRC_HOST:The landing page, the settings page,
parleyctland 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 checkprobes 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: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.comto probe the host directly while DNS is still wrong, and-jsonfor a machine-readable report. It exits non-zero if any check fails.
How it fits together
- 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
helloback. 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.
| flag | env | meaning | default |
|---|---|---|---|
-domain |
PARLEY_DOMAIN |
identity domain this instance serves | required |
-data |
PARLEY_DATA_DIR |
identity key, databases, peer and membership state | ./data |
-endpoint |
PARLEY_ENDPOINT |
advertised base URL | https://<domain> |
-irc |
PARLEY_IRC_LISTEN |
IRC listener (plaintext; terminate TLS in front) | :6667 |
-http |
PARLEY_HTTP_LISTEN |
HTTP(S) listener for the web UI, discovery, inbox and feeds | :8443 |
-irc-host, -irc-port |
PARLEY_IRC_HOST, PARLEY_IRC_PORT |
where clients reach IRC over TLS | endpoint host, 6697 |
-tls-cert, -tls-key |
PARLEY_TLS_CERT, PARLEY_TLS_KEY |
serve HTTPS directly | off |
-admin-token |
PARLEY_ADMIN_TOKEN |
bearer token for the admin API and parleyctl |
off |
-admin (repeatable) |
PARLEY_ADMINS |
nicks that are admins regardless of their stored role | none |
-seed (repeatable) |
PARLEY_SEEDS |
peer domains to link with at startup | none |
-irc-proxy (repeatable) |
PARLEY_IRC_PROXIES |
CIDRs allowed to prepend a PROXY header to an IRC connection | none |
-http-proxy (repeatable) |
PARLEY_HTTP_PROXIES |
CIDRs whose X-Forwarded-For the web listener believes |
none |
PARLEY_TRUSTED_PROXIES and PARLEY_TRUSTED_* |
identity headers from listed proxies (see docs/AUTH.md) | off | |
PARLEY_OIDC_* |
OpenID Connect provider (see docs/AUTH.md) | off | |
HAVEN_SOCKET |
Home Cloud app socket for household sync | off | |
-ca, -insecure, -resolve, -debug |
PARLEY_DEBUG |
local development | off |
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:
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.
| setting | meaning | default |
|---|---|---|
motd |
message of the day (empty means the built-in text) | built-in |
history_replay |
messages replayed on JOIN (channels and DMs alike), unread DMs on login, and the backlog a second client attaching to the same account is shown | 50 |
history_retention |
how long channel and DM history is kept (-1 forever) |
8760h |
message_rate, message_burst |
how much one account may say: messages per second, and how many at once. A rate of 0 is no limit. Bots are exempt |
1, 20 |
max_conns |
concurrent IRC connections to the instance (-1 unlimited) |
1024 |
max_conns_per_account |
concurrent IRC connections per account (-1 unlimited) |
8 |
max_conns_per_addr |
concurrent IRC connections per client address (0 off) |
0 |
federation.policy |
open, or allowlist to federate only by invitation |
open |
federation.inbox_rate, federation.inbox_burst |
inbox deliveries per peer and per address | 20, 200 |
federation.feed_rate, federation.feed_burst |
feed reads per address | 2, 20 |
federation.catch_up_window |
how far back a reconnecting instance asks for | 168h |
auth.local |
show the username/password login form | true |
auth.auto_link |
a first SSO login may claim an unlinked local nick | true |
auth.session_ttl |
web login lifetime | 720h |
auth.rate_limit.* |
login rate limiting (see docs/AUTH.md) | on |
metrics_public |
serve /metrics without authentication |
false |
network_name |
what this instance calls itself, on IRC and on the web (empty means the domain on IRC, "Parley" on the web) | empty |
theme_color |
the colour a phone tints its browser bar with, as #rrggbb |
built-in |
operator |
who runs this instance, shown in the web footer | empty |
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 <topic>) 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:
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:
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
<data_dir>/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:
If the key ever leaks, replacing it is a supported operation rather than a disaster:
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.<fingerprint>.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:
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:
Project layout
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
/banabove. - 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.
Originally published on Hacker News (Best)








