karazajac/rIRCd

A Rust-based IRC server with bleeding edge capabilities.

Rust

0

174 commits

updated Sep 19, 2026

See the code

README

rIRCd

A bleeding-edge IRC server in Rust, following IRCv3 specifications.

Requirements

  • Rust (stable, 2021 edition or later)
  • MariaDB or MySQL server

Initial Setup

1. Set up the database

Create a database and user in MariaDB:

CREATE DATABASE rircdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'rirc'@'localhost' IDENTIFIED BY 'your-password';
GRANT ALL PRIVILEGES ON rircdb.* TO 'rirc'@'localhost' WITH GRANT OPTION;
FLUSH PRIVILEGES;

The database schema (tables for users, channels, channel history, etc.) is created automatically on first startup — no SQL migration files to run.

2. Install rIRCd

cargo build --release
sudo cp target/release/rircd /usr/local/bin/

3. Run the interactive setup

sudo rircd init

This starts an interactive prompt that asks for:

  • Server hostname and network name
  • Plain-text port (default: 6667) and optionally a TLS port with cert/key paths
  • Database credentials (host, port, name, user, password)
  • Message of the day
  • Optionally, email verification for account registration (SMTP host, port, from address)
  • Optionally, an IRC operator account (password is bcrypt-hashed automatically)

A config.toml is written to /etc/rIRCd/ with all your answers filled in. If the file already exists you are asked before overwriting.

Example session:

rIRCd interactive setup
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Press Enter to accept the [default] value.

[Server]
  Server hostname [irc.example.com]: irc.mynetwork.org
  Network name [rIRCd]: MyNet
  Plain-text IRC port [6667]:
  Message of the day [Welcome to rIRCd!]: Welcome to MyNet!

[TLS]
  Enable TLS listener? [y/N]: y
  TLS port [6697]:
  Path to certificate (PEM) [/etc/rIRCd/cert.pem]:
  Path to private key (PEM) [/etc/rIRCd/key.pem]:

[Database]
  (rIRCd requires MariaDB/MySQL for user accounts and channel history.)
  Database host [localhost]:
  Database port [3306]:
  Database name [rircdb]:
  Database user [rirc]:
  Database password:

[Email verification]
  (Requires accounts registered with REGISTER to confirm an address with VERIFY.)
  Enable email verification? [y/N]:

[IRC Operator]
  Create an IRC operator account? [Y/n]:
  Operator name [admin]:
  Operator password:
  Operator password (confirm):

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Config written to /etc/rIRCd/config.toml
The database schema is created automatically on first startup.

Start the server with:  rircd run

You can always hand-edit /etc/rIRCd/config.toml afterwards — see Configuration Reference below.

4. Start the server

sudo rircd run

Connect with any IRC client to localhost:6667 (or whatever port you configured).


Configuration Reference

The only file rIRCd needs is /etc/rIRCd/config.toml. All user accounts, channels, and message history are stored in MariaDB.

[server]

KeyDefaultDescription
namerIRCd.localServer hostname shown to clients
casemappingasciiWhich names count as the same name. ascii folds A–Z and nothing else, and is what a network started today should use. rfc1459 also folds []\~ onto `{}
listen[":6667"]Plain-text listener addresses
listen_tls[]TLS listener addresses (requires [tls])
listen_ws[]WebSocket listener addresses (e.g. [":7667"])
listen_wss[]WebSocket-over-TLS listener addresses (requires [tls])
motd"Welcome to rIRCd!"Message of the day (inline text, multiline OK)
registration_timeout_secs60Time allowed to complete NICK/USER before disconnect
ping_timeout_secs90How long to wait for PONG before sending next PING
disconnect_timeout_secs150Time after missed PONG before disconnecting client
nick_protectiontrueReserve a registered nick for its account; others get 433
trusted_proxies[]Addresses whose X-Forwarded-For is believed, for the WebSocket listeners and the file host alike. Both usually sit behind a reverse proxy, and the header is how the proxy says who the client is — but it is only a header, and anybody can send one. Believed from nobody by default, so the connecting address is used. The last entry is taken, not the first: a proxy appends what it saw, so a client that sends a header of its own pushes its lie to the left. If you serve WebSockets or the file host behind nginx or similar, add the proxy's address here or every client will look like the proxy — which for the file host means one failed-login budget for everybody who came through that door
channel_creationanyoneWho may bring a new channel into being: anyone, accounts or opers. Only creating is gated, never joining one that already exists. accounts is the useful one — a channel made by somebody not logged in has no founder and never gets one, so requiring an account makes every channel owned from its first moment
admin_name / admin_location / admin_email(unset)Shown by ADMIN (256–259)
client_tag_deny(unset)List of client-only tags to drop (e.g. ["+typing"] or ["*"] to drop all)
cloak_key(unset)If set, connecting clients receive an HMAC-SHA256-based virtual host cloak (e.g. "mysecret")
descriptionrIRCd v<version>One-line description of this server, shown by LINKS
register_before_connecttrueAllow REGISTER before the handshake finishes; advertised as before-connect
multiclientfalseLet one account hold several connections at once — a desktop and a phone, say. They share a nick and a single place in every channel; anything addressed to the user reaches all of them, while the answer to a command goes back to the connection that sent it, and each connection sees only the message tags it negotiated. Needs SASL: a connection joins an account it has proved it belongs to, and no other
persistent_sessionsfalseTreat an account as one continuing session: logging in takes its nick back from an earlier session and rejoins that session's channels. Off by default — it disconnects the earlier session, which is what someone reconnecting after a dropped link wants and what someone with two clients open does not
password(unset)A password every connection must send with PASS before registering. Stored in the clear on purpose: it is shared with everyone allowed on the server, so it is a door key rather than a secret about any one person. A wrong or missing one gets 464 and the link is closed

Two or more rIRCd servers form one network. The protocol, and why it is not TS6, is in docs/server-linking.md.

[server]
sid = "1AA"                      # this server's identity on the network
listen_links_tls = ["0.0.0.0:7000"]  # server traffic never shares a client port

[[links]]
name = "irc2.example.org"
sid = "2BB"
host = "10.0.0.2"
port = 7000
send_password = "what we send"
receive_password = "what we expect"
tls = true
fingerprint = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
autoconnect = true
KeyDefaultDescription
sid(derived from the name)Three characters, a digit then two alphanumerics, unique on the network. Derived from the server name when unset, which is a coin toss for a network — set it before linking
listen_links[]Addresses to accept links on. A client that reaches one gets nothing, and a server that reaches a client port is treated as a client
listen_links_tls[]The same, encrypted with the certificate in [tls]. A link carries every private message that crosses it, so this is what a link between two machines should be
tlsfalseDial this peer over TLS. Needs a fingerprint, and needs [tls] set on this server too, because the peer pins this one in the same way
fingerprint(unset)SHA-256 of the peer's certificate, as openssl x509 -in cert.pem -noout -sha256 -fingerprint prints it. This is what says the far end is the server meant rather than whoever answered; there is no list of certificate authorities here, which is also why a self-signed certificate is as good as any other. A link told which certificate to expect refuses a peer that presents none, so the plaintext port is not a way around it
send_password / receive_password—Separate on purpose: each direction has its own secret, so one leaked configuration does not let the holder link both ways
autoconnectfalseKeep the link up, retrying with a widening delay

An operator with the links privilege can also shape the network by hand: CONNECT <server> dials a [[links]] block now rather than waiting for its next retry, and SQUIT <server> [:<reason>] drops the link to a directly attached server, telling it why first so the far end logs a decision rather than a failure. A server behind a peer is that peer's to drop. Both are announced to every operator, and a link that autoconnect keeps up comes back on its own after a SQUIT — set autoconnect = false and REHASH first if it is meant to stay down.

A link carries everything two servers need to agree on: who is connected, what they are called, which channels they are in and what is said in them — and who those channels belong to. Ownership is carried as accounts rather than nicks, because it has to mean the same person at both ends, and it is merged rather than replaced: two servers that each learned part of the truth end up holding all of it, and where they disagree about a founder the older channel keeps its own. A channel nobody is standing in is bursted too when somebody owns it, so a server joining the network learns whose it is rather than finding out from whoever walks in next.

A link also carries the two things that need an answer rather than an announcement — a WHOIS asks the server somebody is on how long they have been quiet, and a GHOST asks the server a stale session is on to close it. What a link is allowed to say, and what it is not, is in docs/server-linking.md.

tests/smoke/run-link.sh brings up two servers with their own databases on their own ports, links them, and checks what each one knows about the other. --tls runs the same checks over an encrypted link, with a throwaway certificate for each side.

Serving a hidden service

Behind a Tor onion service every client reaches the server from 127.0.0.1. max_connections_per_ip then reads as "this many people may use the server through Tor at once", which is not a rule anybody meant to write — and it cannot be fixed by counting more carefully, because the addresses genuinely are all the same. There is nothing to tell apart.

Give the hidden service a listener of its own and name it:

[server]
listen = ["0.0.0.0:6667", "127.0.0.1:6668"]   # 6668 is the one Tor forwards to

[limits]
max_connections_per_ip = 16                   # still true of the public port
shared_address_listeners = ["127.0.0.1:6668"]
max_clients_behind_one_address = 256

The public port keeps the per-address limit, which is the stronger rule. The onion's port is capped as a whole: a weaker promise, and the strongest one available, because it bounds what the door can let through without pretending to know who is coming through it.

Worth knowing: a host ban lands on every onion client at once, since they are one address, and the cost of a failed login is counted per address too, so one person guessing passwords makes the others wait. WEBIRC does not help here — the connection's slot is claimed before the first line is read, so a gateway that could say who the client really is says it too late to matter.

[network]

KeyDefaultDescription
namerIRCdNetwork name (shown in 005 NETWORK)
icon(unset)URL to a network icon image (advertised as ICON= in ISUPPORT; draft/network-icon)

[database]

KeyDefaultDescription
hostlocalhostMariaDB/MySQL host
port3306MariaDB/MySQL port
user(empty)Database username
password(empty)Database password
databaserircdbDatabase name

[tls]

Optional. Both fields must be set to enable TLS listeners. REHASH reloads the certificate in place, so a renewal does not need a restart.

KeyDescription
certPath to PEM certificate file
keyPath to PEM private key file
client_certsIf true, request TLS client certificates for SASL EXTERNAL (default false)

Example:

[server]
listen_tls = [":6697"]

[tls]
cert = "/etc/rIRCd/cert.pem"
key  = "/etc/rIRCd/key.pem"

[limits]

KeyDefaultDescription
max_channels_per_client50Max channels a single client may join
max_channels_per_account30How many channels one account may have registered to it at once; 0 for no limit. Creating a channel while logged in registers it, and a registration is a row that outlives the visit — joining and leaving in a loop makes hundreds an hour through the ordinary flood allowance, and they stay until [expiry] takes them, which an operator may never have turned on. Past this many the channel is still made and the person still gets @ in it; it simply is not written down as theirs, and they are told so and how to make room
max_connections_per_ip16Connections allowed from one address at a time; 0 for no limit. An IPv6 address counts by its /64 — the smallest allocation anybody is given — so a client that picks a fresh address per connection is still one client. Read at startup, not on rehash
max_connections_per_ip_per_minute30New connections one address may make in a minute; 0 for no limit. The concurrent limit never sees a client that connects and hangs up in a loop, and each of those costs a handshake. Read at startup
max_registrations_per_ip30Registrations one address may make in ten minutes; 0 for no limit. Each is a hash, a row, and with [email] a message to an address the client chose. Thirty in ten minutes is an office behind one NAT on its first day; a hundred is a script. Read at each attempt, so a rehash changes it
shared_address_listeners[]Listeners where every client arrives from the same address — a Tor hidden service, or anything behind a local proxy. The per-address limit cannot mean anything there, so connections on these are counted against the listener instead. Must name a listen, listen_tls, listen_ws or listen_wss address exactly; the server refuses to start if it does not, because a typo would silently leave the listener capped at max_connections_per_ip. Read at startup, not on rehash
max_clients_behind_one_address256The cap that stands in for the per-address one on those listeners; 0 leaves only max_clients
max_remote_users250000Users this server will hold on behalf of other servers; 0 for no limit. A link is trusted with what it says about its users, not with how many of them there is room for: past this, further introductions are refused and logged, and the link stays up
max_servers512Servers this one will know about, direct peers included; 0 for no limit. No network has more than a few hundred
max_clients10000Connections allowed in total; 0 for no limit. Every other limit is per address, and anybody with IPv6 has a great many /64s to rent, so this is the one they meet
max_line_length512Longest message body accepted, before tags; advertised as LINELEN
flood_burst10Commands a client may send back to back before being throttled
flood_rate1Commands per second the flood allowance refills at
min_password_length6Shortest password REGISTER accepts; advertised in draft/account-registration
max_targets4Recipients one PRIVMSG, NOTICE, TAGMSG or KICK may name at once; advertised as TARGMAX

[[opers]]

Define IRC operators. Multiple [[opers]] blocks are allowed.

[[opers]]
name = "admin"
hostmask = "*"            # optional; restrict by host
password_hash = "$2a$..." # generate with: rircd genpasswd

[[opers]]
name = "helper"
password_hash = "$2a$..."
privileges = ["kill", "ban"]   # omit for all privileges

privileges limits what an operator may do: kill, ban (KLINE/UNKLINE, DLINE/UNDLINE), rehash, die, sethost, wallops, channels (CHANOWN on a channel that is not theirs), links (CONNECT and SQUIT). Omitting the key keeps the previous behaviour, where every

An operator block's hostmask (user@host or nick!user@host, judged on the real address, not the cloak) is where that operator is allowed to be; OPER from anywhere else is refused with 491. An operator block may ask for more than a password. require_tls = true refuses OPER over a connection that is not TLS, so the password never crosses a wire in the clear. certfp = "SHA256:…" (the fingerprint as openssl x509 -noout -sha256 -fingerprint prints it, colons optional) refuses OPER unless that client certificate was presented — as a second factor when password_hash is set too, and on its own when it is left empty, in which case OPER <name> with no password is the whole ceremony.

[[opers]]
name = "kara"
certfp = "3B:8F:…:A1"
require_tls = true
privileges = ["kill", "ban", "channels"]

kill also covers SANICK <nick> <newnick>, the milder cousin of KILL: somebody sitting on a name they should not have is moved off it rather than off the network, told who did it, and the network sees an ordinary nick change — a user on another server is renamed by that server at this one's request. operator may do everything — so this only narrows operators who were already narrowed, and channels has to be listed for them to move a channel.

[filehost]

Optional, and how somebody shares a picture here. There is no upload bot and no third-party host: the server keeps the file itself. A client reads the FILEHOST token out of ISUPPORT, POSTs the file there over HTTPS with the same account credentials it uses for SASL — HTTP Basic, because that is what the extension says — and is handed back a URL in the Location header to paste into the channel. Anybody with the link can fetch it; only somebody with an account can put one there.

KeyDefaultDescription
listen0.0.0.0:8080Where the file host listens
public_url(required)The base URL people will actually reach it on (e.g. https://irc.example.com/uploads). Its path is where the routes are mounted, so a reverse proxy in front needs no special case, and an https:// one makes the server serve it with the certificate from [tls]
upload_dir/var/lib/rircd/uploadsWhere the files go
max_size52428800 (50 MiB)The largest file it will take; anything over gets 413
max_uploads_per_hour60How many files one account may put there in an hour; 0 for as many as it likes, which on a server anybody can register on means as much disk as it likes. Over it is 429 with a Retry-After

The credentials are the account's, so guessing at them here costs what guessing at them over IRC costs: the same allowance is spent per address, and an address that keeps getting it wrong is answered 429 with a Retry-After without a password being checked at all. Otherwise every bit of the care the IRC side takes over passwords would be one HTTP request away from being beside the point.

A file is stored under a name of the server's choosing — a UUID, keeping only a sanitised extension — so nothing an uploader writes becomes a path, and nothing they upload can overwrite anything. tests/smoke/test_filehost.py covers the lot, including the links that try to leave the directory.

Two doors to one file host

A network reachable both by name and as a hidden service has two front doors to one file host. A client that arrived through the onion service and is handed the public name has been asked to leave Tor to fetch a file — which is the one thing it was trying not to do — and its client may simply refuse, so sharing a file does not work for it at all.

[[filehost.alternates]]
listener = "127.0.0.1:6668"                  # the port the onion service maps to
public_url = "http://xyz…onion/uploads"

The FILEHOST token in ISUPPORT is then chosen per client, by the listener it arrived on. One upload directory, one set of routes, two names for it: the onion service points at the same file host, so the files are the same either way.

The link handed back matches too. Behind a proxy every door arrives on the same socket, so the file host cannot tell them apart the way the IRC side can — what tells them apart is the name the client dialled, which the proxy passes on in Host. That header is not used as a URL: it only chooses among the ones written here, so the worst a made-up one can do is pick another of your own doors. Point the proxy at the file host with the client's Host preserved, which is the default in Caddy and nginx's proxy_set_header Host $host.

Keep the path the same in both (/uploads here) and change only the scheme and host. Routes are mounted under the path the primary public_url names, so an alternate with a different one would hand out links that answer nowhere — the server says so at startup rather than leaving you to find out.

What this does not do is rewrite links people paste in chat. A link is text once it is sent, so a file shared by a Tor user is an onion link to everybody, and one shared by a clearnet user is a clearnet link — including to the people on the onion service, who will have to decide for themselves whether to follow it. The server chooses which door each client is told to use, and gives them a link on that door; it does not edit what anybody says.

Behind a reverse proxy, set [server] trusted_proxies to the proxy's address. Without it every request looks like it came from the proxy, so everybody who came through that door shares one failed-login budget — and whoever is guessing spends the allowance of every real user behind it. That is worth more than it sounds on a network reachable over Tor, where the door those users arrive by is the same one.

That last case has no header to fix it: a hidden service hands over no per-user address, so those clients share one whatever is read. So a failed check is charged twice — once to the address and once to the account being tried — and the wait is whichever has earned more. An account under guessing is slowed wherever the guessing comes from, and one guesser on a shared address no longer has to be paid for by everybody else on it. A right answer gives both back.

Nothing reclaims that disk on its own. [expiry] uploads_days is how you say how long a file is kept; without it the server says so at startup, because a directory that only ever grows is worth knowing about before it matters. The sweep touches regular files in the upload directory and nothing else — not a symbolic link, not a subdirectory, not a dotfile.

Example:

[filehost]
listen = "0.0.0.0:8080"
public_url = "https://irc.example.com/uploads"
upload_dir = "/var/lib/rircd/uploads"
max_size = 52428800

[email]

Optional. Turns on email verification for account registration (draft/account-registration VERIFY). With this section present, REGISTER requires a real address, the account is stored unusable, and a code is mailed to the address; the account only becomes usable once VERIFY confirms the code. The email-required token is added to the advertised draft/account-registration capability so clients know to ask for an address.

Mail is sent over SMTP with rustls — no system TLS libraries needed.

KeyDefaultDescription
smtp_host(required)SMTP server hostname
smtp_port587SMTP port (587 STARTTLS, 465 implicit TLS, 25 plain)
smtp_user(unset)SMTP username; omit for an unauthenticated relay
smtp_password(unset)SMTP password
encryptionstarttlsstarttls, tls (implicit) or none
from(required)From address, e.g. ExampleNet <noreply@example.com>
subjectYour IRC account verification codeSubject line
code_expiry_secs86400How long a code stays valid
reset_subjectYour IRC password reset codeSubject line of a RESETPASS mail. Reset codes are good for fifteen minutes regardless of code_expiry_secs: they arrive by mail that anybody holding the inbox can use
mail_gap_secs900The least time between two messages to one address, whoever asks and by whichever of REGISTER or RESETPASS. Asking for a message is free and the address is the client's to choose, so the address itself gets a say. The gap starts only when a message actually goes out, so a registration refused for its password does not cost the person their real attempt
[email]
smtp_host = "smtp.example.com"
smtp_port = 587
encryption = "starttls"
from = "ExampleNet <noreply@example.com>"
smtp_user = "noreply@example.com"
smtp_password = "s3cr3t"
code_expiry_secs = 86400

[webpush]

Optional. Enables Web Push notifications (draft/webpush, RFC 8030/8291/8292) so mobile and web clients can be woken for messages while their app is asleep. When configured, the draft/webpush capability is advertised and clients register endpoints with WEBPUSH REGISTER.

A VAPID key pair is generated on first start and stored in key_file (mode 0600). Its public key is advertised to capable clients in the VAPID= ISUPPORT token and used to sign every push request — back this file up: replacing it invalidates every existing subscription.

KeyDefaultDescription
contact(required)VAPID sub claim — a mailto: or https: URL identifying the operator
key_file/etc/rIRCd/vapid.keyWhere the VAPID private key is stored. Generated on first start if missing; an existing key is read in whichever shape it came in — the bare 32-byte scalar this server writes, or the PKCS#8 that web-push, py_vapid and the online generators hand out, base64 or base64url, PEM wrapper or not. They are the same key either way, and the server says which public key it read at startup so it can be checked against whatever the web app was built with
max_subscriptions_per_account5Endpoints one account may have registered at once
ttl_secs3600TTL requested of the push service per notification
max_failures5Consecutive delivery failures before a subscription is dropped
allow_private_endpointsfalsePermit endpoints resolving to loopback/private addresses (testing only)

Bringing your own key: put it in key_file before the first start and the server uses it — no conversion, whatever your generator produced. It logs the public key it derived, which is the one the browser needs in applicationServerKey; if that does not match what the web app has, the subscriptions it creates cannot be decrypted by this server. Keep the file to the server and treat it the way you treat the TLS key: anything holding it can send notifications as this network.

[webpush]
contact = "mailto:admin@example.com"
key_file = "/etc/rIRCd/vapid.key"
max_subscriptions_per_account = 5
ttl_secs = 3600

Subscriptions belong to an account, so a client must be logged in (SASL or REGISTER) before WEBPUSH REGISTER; they are stored in MariaDB and survive restarts.

WEBPUSH REGISTER <endpoint> p256dh=<key>;auth=<secret>
WEBPUSH UNREGISTER <endpoint>

Endpoints must use https and must not resolve to loopback, private, link-local or carrier-grade-NAT addresses. A notification carries exactly one IRC message (with msgid, time and account tags), encrypted with aes128gcm per RFC 8291, and is sent for:

  • direct PRIVMSG/NOTICE to the user, and
  • channel PRIVMSG/NOTICE that mention their nick.

Notifications also reach users who are not connected: when a registered user joins a channel, that membership is remembered, so a mention still pushes to them while they are away — and the message itself is waiting in channel history when they return. Leaving the channel, or being kicked from it, ends that.

Endpoints the push service reports as gone (404/410), or that fail max_failures times in a row, are removed automatically. Clients should re-send an identical WEBPUSH REGISTER periodically (daily is typical) to keep a subscription fresh.

[dnsbl]

Ask a DNS blocklist about every public address that connects. The addresses that open connections to IRC servers by the thousand are the same ones that do it to everybody else, and somebody has already written them down.

[dnsbl]
zones = ["dnsbl.dronebl.org"]   # the first that answers decides
action = "reject"               # or "warn": let them in, and log
timeout_secs = 2
cache_secs = 600

Each address is asked about once and the answer remembered. A lookup that times out counts as not listed — a resolver outage must never lock everybody out — and addresses that are not public are never asked about, nor is anything arriving on a shared_address_listeners port, since the address there is everybody's. A listed address is turned away before its first line with ERROR :Closing link: Your address is listed in <zone>.

[webirc]

Optional. Lets a gateway — a web client, a bouncer, a Tor front end — say what address a connection really came from, so the person behind it is judged on their own address rather than the gateway's.

[webirc]
password = "gateway-secret"
hosts = ["203.0.113.5", "10.0.0.0/8"]   # only these may say it
KeyDefaultMeaning
password(required)What the gateway sends as the first parameter of WEBIRC. Compared in constant time
hosts[] (nobody)The addresses allowed to use it, exactly or as a network in CIDR form. Empty means nobody, and the server says so at startup

That address is the one fact everything else rests on: the K-lines and D-lines, a channel ban on a host, the blocklist, the cloak, and the budget a failed login spends. A password on its own guarded it, and a password is handed to whoever runs the gateway, written into their configuration, and read by somebody else eventually — so it is guarded the way trusted_proxies guards X-Forwarded-For: believed only from an address you named.

Empty means nobody rather than everybody, because a list that means everybody when left blank is a list nobody fills in. A connection from anywhere else is refused with the same answer a wrong password gets, and told nothing about whether it guessed right.

Full example config

[server]
name = "irc.example.com"
listen = [":6667"]
listen_tls = [":6697"]
motd = """
Welcome to Example IRC!
Have fun and be nice.
"""
registration_timeout_secs = 60
ping_timeout_secs = 90
disconnect_timeout_secs = 150

[network]
name = "ExampleNet"

[database]
host = "localhost"
port = 3306
user = "rirc"
password = "s3cr3t"
database = "rircdb"

[tls]
cert = "/etc/rIRCd/cert.pem"
key  = "/etc/rIRCd/key.pem"

[limits]
max_channels_per_client = 50

[[opers]]
name = "admin"
hostmask = "*"
password_hash = "$2a$12$..."

[filehost]
listen = "0.0.0.0:8080"
public_url = "https://irc.example.com/uploads"
upload_dir = "/var/lib/rircd/uploads"
max_size = 52428800

[email]
smtp_host = "smtp.example.com"
smtp_port = 587
encryption = "starttls"
from = "ExampleNet <noreply@example.com>"
smtp_user = "noreply@example.com"
smtp_password = "s3cr3t"

[webpush]
contact = "mailto:admin@example.com"
key_file = "/etc/rIRCd/vapid.key"

Reloading

REHASH from an operator and SIGHUP from outside do the same thing: read the configuration again and put it in place without dropping anybody. The certificate is re-read too, which is the point of it — renewals happen on somebody else's schedule, and restarting to pick one up would disconnect everyone.

systemctl reload rircd        # the unit file sends SIGHUP
kill -HUP "$(cat /etc/rIRCd/rircd.pid)"

For Let's Encrypt, that is the deploy hook:

certbot renew --deploy-hook 'systemctl reload rircd'

What survives the reload is everything a connection is holding: the database pool, the history writer, the servers already linked, and the Web Push key — rotating that last one would invalidate every push subscription registered under it. A configuration that will not load leaves the running one alone and says why in the log.

[[classes]]

[limits] says what a client may do, which is the right answer only when every client is the same one. A gateway carrying two hundred people from one address, a bot that must not be pinged out while it thinks, and somebody on a phone are not the same thing, and a server with one set of numbers is a server tuned for whichever of them it would rather lose.

[[classes]]
name = "gateway"
hosts = ["10.0.0.5", "192.168.0.0/16"]
max_per_ip = 0          # this address may bring as many as it likes
max_clients = 500       # but no more than this many at once, all told
ping_secs = 300
sendq = 8192

[[classes]]
name = "everybody"      # no hosts: the one everybody else falls into
max_per_ip = 5
KeyMeaning
hostsAddresses in this class: globs (10.0.0.*, *.example.org) or networks in CIDR form. Empty matches everybody, so that class goes last
max_clientsHow many connections this class may hold at once. The one over it is turned away with This class of connection is full
max_per_ipHow many one address may hold within this class; 0 for as many as it likes
ping_secsHow long a connection here may be quiet before it is pinged
flood_burst, flood_rateCommands it may send back to back, and how fast the allowance refills
sendqMessages that may be queued for it before it is dropped for not reading. A gateway needs a deeper queue than a phone

A connection lands in the first class whose hosts it matches. Anything a class leaves unset falls through to [limits] and [server], so adding a class changes nothing except what it says. STATS y lists the classes with how many connections are in each, and TRACE calls each connection by its class — falling back to how it arrived (plain, tls, websocket) when no class named it.

Classes are read when the server starts, not on REHASH: the part of the server that accepts connections took its copy at startup. STATS y reports that copy rather than the file, so it never claims a class that is not in force.

[expiry]

What a services package would call nick and channel expiry. Off unless set: this is a policy about other people's names and rooms, and a server with real people on it should choose it rather than be handed one.

[expiry]
accounts_days = 365   # erase an account nobody has logged in to for a year
channels_days = 90    # give up a registration nobody holding it has visited for 90 days
uploads_days = 30     # let go of a shared file after a month
KeyDefaultMeaning
accounts_days0 (never)An account nobody has logged in to for this long is erased: its nick is free again, its grouped nicks with it, its profile and read markers go with it, and the channels it founded are left without a founder — the people in them are told, as with DROPACCOUNT
channels_days0 (never)A registration nobody who holds the channel — founder or standing operator — has been in for this long is given up. The channel keeps its topic and its modes; it is simply nobody's, and the next person in gets @ the way they would in any fresh channel
uploads_days0 (never)A file shared through [filehost] is let go of this long after it was uploaded, and its link becomes a 404. Kept by age rather than by whether anybody still wants it, because nothing here knows who has a link — so this is really a statement of how long a link is good for, which is a thing people can be told in advance. Unlike "until the disk fills", which is what leaving it unset means

An operator can keep a name or a room out of expiry's reach with NOEXPIRE <account|#channel> ON (OFF puts it back; without either it says which it is). What a services package calls the same thing.

"Unseen" is measured the way a person would measure it. A login is use, and so is a holder standing in the room — including one who connected months ago and never left: the sweep looks at who is connected before it looks at the clock, and refreshes the clock for them rather than expiring them. It runs a couple of minutes after startup, once an hour after that, and on every REHASH, so a changed policy can be seen to work. The clock starts the first time this version runs, so nobody expires on the day of an upgrade. Operators are told what each sweep let go of, and what it let go of is announced across links the way DROPACCOUNT and CHANACCESS withdrawals are.

CLI Commands

CommandDescription
rircd init [--dir /etc/rIRCd]Create config directory with a default config.toml
rircd [--config /etc/rIRCd/config.toml] runStart the server; connects to DB, inits schema, writes PID file
rircd [--config /etc/rIRCd/config.toml] stopSend SIGTERM to the running server (Unix only)
rircd [--config /etc/rIRCd/config.toml] statusCheck if the server is running via PID file
rircd genpasswdInteractively hash a password for use in [[opers]]
rircd adduser <nick> [password]Create an account without connecting as a client; reads the password from stdin when omitted

--config is a global flag, so it goes before the subcommand.

The PID file is written to the same directory as config.toml (e.g. /etc/rIRCd/rircd.pid) and is removed on clean shutdown. rircd stop and rircd status use it to find the process.


User Accounts

User registration is handled via the IRC REGISTER command (draft/account-registration):

REGISTER * [email|*] <password>

The account name must be * or your current nick — the server advertises before-connect (so registering during connection negotiation, before CAP END, works) but not custom-account-name.

This stores a bcrypt hash of the password (for SASL PLAIN) and full SCRAM credentials (for SASL SCRAM-SHA-256) in MariaDB. Passwords must be at least 6 characters. On success the client is logged in immediately, exactly as if it had authenticated with SASL.

Email verification

If an [email] section is configured, registration takes a second step. REGISTER then requires a real address, and replies:

REGISTER VERIFICATION_REQUIRED <account> :A verification code has been sent to <email>

The account exists but cannot authenticate — SASL PLAIN, SCRAM-SHA-256 and EXTERNAL all refuse it — until the mailed code is confirmed:

VERIFY <account|*> <code>

On success the server replies VERIFY SUCCESS <account> and logs the client in. Codes are compared in constant time, are case-insensitive, and expire after code_expiry_secs (24h by default); an unverified account whose code has expired is deleted, freeing the name for registration again. Expired rows are also swept at startup.

If the mail cannot be sent, the registration is rolled back and the client receives FAIL REGISTER TEMPORARILY_UNAVAILABLE.

Authentication is supported via two SASL mechanisms:

SASL PLAIN:

CAP REQ :sasl
AUTHENTICATE PLAIN
AUTHENTICATE <base64-encoded NUL-separated authzid NUL authcid NUL password>

SASL SCRAM-SHA-256 (recommended):

CAP REQ :sasl
AUTHENTICATE SCRAM-SHA-256
AUTHENTICATE <base64-encoded client-first-message>
AUTHENTICATE <base64-encoded client-final-message>

SCRAM-SHA-256 uses PBKDF2 key derivation (4096 iterations) and provides mutual authentication — the server proves it knows your credentials without ever seeing your password in clear text over the protocol exchange.

Accounts are keyed by nick (lowercase). There is no separate admin interface for user management — use direct SQL queries on the users table if needed.


Reserved names

RESV [<seconds>] <pattern> :<reason> keeps a name for the network. A pattern beginning with # is about channels and anything else about nicks, so #help* never stops anybody being called helpdesk.

RESV #staff :for the people who run this place
RESV *serv :nobody here speaks for services

Somebody who asks for a reserved channel is told why (479), and somebody who asks for a reserved nick likewise (432). That is the difference between this and a spam filter: a filter is quiet on purpose, because telling a spammer what caught them hands them the way around it, while a reservation is a rule people are meant to be able to read.

Operators are not held to it — reserving the staff channel and then being unable to enter it would be a strange way to run a network — and a channel that already has people in it keeps them; the reservation stops anybody else coming in. Reservations are stored, cross links, and expire on their own when given a duration. UNRESV <pattern> gives the name back.

Shuns

A K-line closes the connection, which tells whoever was behind it to come back from another address. SHUN [<seconds>] <nick!user@host> :<reason> leaves them connected and lets nothing they say reach anybody:

  • They may listen, answer a PING, and leave. Everything else they type quietly does nothing — no error, no numeric, no hint. A shun that announced itself would just be a slower kill.
  • QUIT and PART still work, without the parting words: a quit reason is shouted into every channel they were in, so it is dropped with the rest.
  • They are never told, and neither is anybody else except the operators, under snomask b. STATS s is the operators' too, for the same reason: a list anybody could read would tell the shunned person they had been shunned.

Shuns live beside K-lines and D-lines: stored in the database, carried to every server when set, listed by STATS s, lifted by UNSHUN, and expiring on their own when given a duration. A mask made only of wildcards is refused, and so is one covering the operator setting it.

What a secret channel gives away

Nothing, and that takes saying, because it is the reply that leaks rather than the data. A stranger asking about a +s channel gets exactly what they would get for a channel that does not exist — the same numeric, the same text — from PRIVMSG, NOTICE, TAGMSG, KNOCK, TOPIC, NAMES, WHO, MODE, CHATHISTORY, LIST and CHANACCESS alike. One command answering differently is the whole of what +s was meant to hide, and the message is not delivered either.

A channel's own business stays inside it. The ban, exception and invite lists (MODE #channel b, e, I) are for the people in the channel: a stranger is told they are not on it, or for a secret channel that there is no such channel. WHO on a channel lists the members to the members; somebody carrying +i is listed to the people they share a channel with and to nobody else.

A new channel is created +nt. Without +n anybody may talk into a room without joining it, which is how one spammer reaches a hundred rooms without ever appearing in one; without +t any of the people in it may rewrite the topic. An operator who wants either off can take it off.

Who stands where in a channel

^ founder   +x   the person the channel belongs to
& admin     +a   somebody they have put above the operators
@ operator  +o
% half-op   +h
+ voice     +v

Status is given and taken by people who have some of their own, and nobody reaches above themselves: an operator may op an operator as they always could, but not appoint an admin over their own head, and an admin may not unseat the founder. A server operator stands outside the ladder.

A half-operator is the one exception to "an equal may act on an equal": they may not throw out another half-operator, or appoint one. Two of them could otherwise take a channel apart between them, which is the whole reason the rank is half of something.

^ is not a mode anybody sets, so it moves when the registration moves: CHANOWN takes it off the outgoing founder and gives it to the incoming one there and then, and CHANDROP — or the account expiring, or being dropped — takes it off altogether. A channel that belongs to nobody has nobody wearing it.

^ is not a mode anybody sets on the founder. It comes from the registration, so it is true again every time they walk in and cannot be taken from them by a MODE — CHANOWN is how a channel stops being theirs. & is an appointment for the visit, the way +h is: it is not written down.

The letters are x and a, not the q and a used by the servers that have these. +q here is the quiet list — persisted, taking timed bans, feeding +z — and was not worth breaking for a letter. Clients read the prefixes from PREFIX in ISUPPORT rather than assuming them, so what they show is what this server advertises: PREFIX=(xaohv)^&@%+.

Exemptions

ELINE [<seconds>] <mask> :<reason> says who none of that applies to.

Every blocklist eventually lists somebody who belongs here — a shared address, a VPN, an exit node — and without a way to say so the only answers are to stop believing the list for everybody or to stop asking it. Both give up more than the one address was worth. An exemption is how one address is vouched for while the rest of it stands.

ELINE *!*@10.0.0.0/8 :the office
ELINE 30d *!*@vpn.example :people we know come through here

It covers every kind of refusal this server makes on its own account: a D-line and the blocklist at the door, and a K-line or a shun once there is a nick to judge. It is judged twice — on the address alone before there is a nick, and on nick!user@host after — so being vouched for does not stop when somebody says who they are.

It is not a promise about behaviour. KILL still works, and so does a channel ban: an exemption is about the standing rules, not about an operator's hand.

Like the bans it lives in the database, goes to every server when set, expires on its own when given a duration, and is refused if the mask is nothing but wildcards — that one would vouch for the network and undo every ban at once. STATS e reads them back and UNELINE stops vouching. Unlike a ban, a mask covering the operator setting it is allowed: that check exists to stop somebody shutting themselves out by accident, and an exemption cannot.

A mask and a kind together are what name an entry, so a K-line, a shun and an exemption can each stand against the same mask and lifting one leaves the others.

Spam filters

SPAMFILTER is a pattern, what it is looked for in, and what happens when it is found — what other servers call the same thing. Operators with the ban privilege.

SPAMFILTER ADD <targets> <action> [<seconds>] :<pattern>
SPAMFILTER DEL <id|pattern>
SPAMFILTER LIST
SPAMFILTER TEST :<text>
PartMeaning
targetsLetters: p private messages, c channel messages, n nicks, t topics, q quit reasons, r real names — or * for all of them
actionwarn (let it through and tell the operators), block (refuse the line), kill (refuse it and close the connection), kline / dline (refuse it and ban the address for <seconds>, 0 for no end)
<pattern>A glob, matched as a ban mask is — say *phrase* to catch it anywhere in a line. Between slashes (/…/) it is a regular expression instead

A pattern made only of wildcards is refused, and so is one that matches the nick or real name of the operator adding it. Operators are never filtered: a filter that killed the person who could lift it is a server nobody can get back into. The sender is never told which pattern caught them — that would be handing them the way around it — so a blocked message is simply "not delivered", a blocked topic "not set", and a filtered quit reason becomes "Quit". The operators are told, under snomask f, and so is the log.

Filters are kept in the database, carried to every server when they are set, and bursted to a server that links afterwards, so the network agrees on them. Server bans and reservations are bursted the same way, capped at 512 of each: a server that has been running for years has a ban list that is not a greeting. SPAMFILTER TEST asks what a line would hit without anybody sending it, and LIST shows each filter's id, what it watches, and how many times it has matched since the server started. At most 64 filters, each pattern at most 512 characters: every line a client sends is held against all of them on the one dispatch loop, so the cost is a budget rather than a preference. Regular expressions are matched in time linear in the line, so a pattern cannot be made to hang the server.

Looking after your own account

There is no NickServ to message, so the account commands are commands. REGISTER and VERIFY make one, PASSWD changes its password, RESETPASS recovers it, GROUP reserves the other nicks you go by, and DROPACCOUNT ends it. Two more fill the gaps:

ACCOUNTINFO says what the server is holding about you — when the account was registered and last seen, the address on it, every nick grouped to it, the channels it founded, whether an operator has kept it out of the expiry sweep. An operator with the ban privilege can ask about somebody else's; nobody else can.

SETEMAIL <current password> <new address> moves the account to another address. The address is what a forgotten password goes to, so this is the one change that could quietly take an account away from the person who owns it, and it asks for two things: the password, which says it is you, and a code read at the new address, which says you can receive there.

SETEMAIL hunter2 me@newhost.example
-> NOTE SETEMAIL SENT me@newhost.example :A code is on its way…
SETEMAIL K7M2QJ4T
-> NOTE SETEMAIL CHANGED alice :alice is now at me@newhost.example

Until the code comes back the account keeps the address it had, so a borrowed session cannot point it somewhere else and wait for a reset. Reading the new address proves as much as reading the first one did, so an account that never verified is verified by this. One code at a time per account, and the address itself is held to the same mail_gap_secs as REGISTER and RESETPASS.

Grouped nicks

An account's own name is reserved for it. GROUP reserves the nick you are using as well — the work nick, the phone nick — so nobody else can sit on it, and so that being logged in is enough to use any of them. Up to five besides the account's own name; GROUP -<nick> gives one back, GROUP * lists them. You have to be using a nick to group it: reserving names you have never been seen under is squatting, and the server does not help with that. A grouped nick goes when the account goes, whether by DROPACCOUNT or by expiry, and cannot be registered as an account by anybody else while it is held.

Accounts, Channels and Moderation

rIRCd has no separate services package: what NickServ and ChanServ do on a traditional network is done by the server itself, against MariaDB.

Traditional services featurerIRCd
Nick registrationREGISTER / VERIFY (draft/account-registration), with optional email verification
IdentifySASL PLAIN, SCRAM-SHA-256 or EXTERNAL — no /msg NickServ
Nick protectionRegistered nicks are reserved for their account (nick_protection)
Nick recoveryGHOST <nick> closes a stale session of your own that is holding it, wherever on the network it is
Password changePASSWD <current> <new> — the current one is asked for even though you are logged in, and your other logins are closed
Password recoveryRESETPASS <account> mails a code; RESETPASS <account> <code> <new> uses it and closes every login to the account
Dropping an accountDROPACCOUNT <password> — removes the account and everything that named it; channels it founded are left with no founder
Channel access listCHANACCESS #channel — the founder, and everyone whose operator or voice status is remembered
Giving a channel upCHANDROP #channel — by the founder, or by an operator with the channels privilege; operators keep their standing
Channel founderThe account that creates a channel; always opped on join, never shut out of it by its own +b/+i/+k/+l, and not kickable or deoppable in it
Channel transferCHANOWN #channel <account> — by the founder, or by an operator, said out loud in the channel
Channel access listsMODE +o / +v on an account is remembered and restored on the next join. Somebody not logged in holds the status while they are there and no longer: a name proves nothing, so remembering one would hand the status to whoever took it next
Channel modes, topic, keyPersisted; kept when an owned channel empties, and restored on startup
Ban lists (AKICK-ish)+b, +e, +I and +q masks are persisted and restored
Network bansKLINE / UNKLINE, persisted and enforced on connect — on every server: a ban set on one crosses the links, closes what it matches there, and is written down there too. A mask made of wildcards is refused, typed or received. Hosts may be networks (*@203.0.113.0/24). DLINE / UNDLINE ban an address or network at the socket, before anything is spent on the connection
Server noticesUser mode +s with a mask of letters says which server notices an operator hears — a accounts, b bans, c connections, f floods, k kills and forced nick changes, l links, n nick changes, o OPER attempts, s server (REHASH, expiry). MODE <you> +s +cn adds, -s clears; a new operator starts with everything but connections and nick changes. HELP SNOMASK
VhostsSETHOST (oper), plus automatic cloaking via cloak_key
Server-side ignoreSILENCE +<mask> / -<mask> / SILENCE to list — a mask is somebody who does not exist to you: no message, notice or invitation of theirs arrives, and nothing tells them so. nick, nick!user@host and ~a:account are all masks; 32 per person, advertised as SILENCE=32

Still absent: per-channel access levels beyond operator and voice, AKICK, and memos.

Owning a channel

Whoever creates a channel while logged in is its founder, which is an account rather than a nick — whoever is logged in as it owns the channel, on any connection and under any name.

A founder is opped whenever they come back, cannot be kicked out of their own channel, cannot be deopped in it, and is not shut out by its own +b, +i, +k or +l. An operator turning on the person who appointed them is how a channel gets stolen, and none of those doors opens that way.

That leaves ownership needing a door of its own, which is CHANOWN:

CHANOWN #channel              what it says: who this channel belongs to
CHANOWN #channel <account>    hand it on

The founder may hand their own channel on — somebody leaves a project and the channel should go where the project went. A network operator may hand on any channel, because one whose founder has vanished, or whose founder is the problem, has no other way out. An operator doing it is announced in the channel as an operator and logged by name: an operator who wants a channel can already kick, mode and ban their way through it, so what keeps them honest is not being unable to act but being unable to act quietly.

The outgoing founder keeps operator status. Handing a channel on is not the same as being thrown out of it. The new owner must be an account that exists, and the change crosses the network as a CACCESS line, so every server agrees about whose channel it is.

CHANACCESS #channel lists who has the run of a channel — its founder, and everyone whose operator or voice status is remembered between visits. A status that is remembered is a status that can be forgotten about, and this is how a founder sees what they have given away. Like CHANOWN, it answers "no such channel" to somebody who cannot see a +s or +i channel.

CHANDROP #channel gives a channel up: the founder may give up their own, and an operator with the channels privilege may take the founder off any. The operators keep their standing, so the channel goes on being somebody's to run; it just stops being anybody's to own, and CHANOWN by an operator is how it gets an owner again. It is said in the channel, whoever did it.

Your account

There is no NickServ to SET PASSWORD at, so the server answers for it:

PASSWD <current> <new>                        change your password
RESETPASS <account>                           have a reset code mailed to you
RESETPASS <account> <code> <new password>     use it
DROPACCOUNT <password>                        remove your account

PASSWD asks for the current password even though you are logged in — a logged-in session is not proof of knowing it — and closes your other logins, since a changed password is meant to lock somebody out and a live session is the somebody. With multiclient, your other devices are the same login and stay.

RESETPASS <account> answers the same way whatever happened, because a command that said "no such account" would be a way of finding out which names are accounts. It sends at most one code per quarter hour to an account, the code is good for that long, and using it closes every login to the account: whoever is resetting the password cannot log in, so any session that exists is somebody else's. It needs [email]; without it there is nowhere to send a code.

DROPACCOUNT removes the account and everything that named it — its profile, its read markers, its push endpoints, its place on every operator and voice list — because a name that is free to register again must not come with a previous life attached. Channels it founded are left with no founder; their operators keep their standing and a network operator can give them a new one.

Every one of these is charged against the same failed-login budget as a bad SASL or OPER attempt, and so is REGISTER itself: each registration is a hash, a row, and with [email] a message to an address the client chose, which unbounded is an open mail relay for anybody who can connect. No address is mailed twice in a quarter hour whoever asks, by REGISTER or RESETPASS. A password check is a password check whatever command it arrives in.

Operators are told, by server notice, when somebody becomes an operator and when somebody fails to. It is how a stolen operator password gets noticed.

A channel made by somebody who is not logged in has no founder, and never gets one — there was no account to write down. channel_creation = "accounts" is the setting that closes that: it gates making a channel, never joining one, so every channel on the network is owned from its first moment. opers makes the channel list something decided rather than grown, which suits a private network and would be hostile on a general one.

Channel Persistence

Channels, topics, modes, operator lists, voice lists, and message history are all stored in MariaDB automatically.

A channel that belongs to somebody — it has a founder, or an operator whose status was granted to last — stays known when the last person walks out, with its modes, bans, topic and access lists intact, so it is still itself when somebody comes back. A channel nobody owns is forgotten when it empties, the way a name two people used for an afternoon should be: modes on their own do not keep one alive, because with nobody able to lift them a +k whose key is forgotten would seal the channel rather than save it.

  • Topic — persisted whenever a channel topic is set; 333 RPL_TOPICWHOTIME and 329 RPL_CREATIONTIME sent on JOIN.
  • Channel modes — mode flags (+imnstRcC), key (+k), user limit (+l), join throttle (+j) and flood limit (+f) are saved to the database on every MODE change, kept when an owned channel empties, and restored on startup.
  • Expiry — a registration is kept for as long as [expiry] says, which by default is forever; see that section.
  • Ban and exception lists — +b, +e, +I and +q masks are saved per channel and restored, so neither an empty room nor a restart makes an owned channel forget who was banned.
  • Operators / Voice — stored per channel by account; whoever logs in to one receives @/+ automatically when they join, under any nick. Status granted to somebody with no account applies while they are present and is not written down. Rows stored under a bare nick by an older version are dropped at startup, with a count in the log — they granted nothing that could be trusted, because nick reservation is what would have protected them and it fails open when the database is unreachable.
  • Direct messages — private conversations are stored per nick pair and replayed by CHATHISTORY <nick>; CHATHISTORY TARGETS lists only the requesting user's own conversations.
  • Message history — PRIVMSG, NOTICE, and channel events (JOIN, PART, QUIT, TOPIC, NICK) are appended (up to 1,000 entries per channel, oldest pruned). Clients with draft/chathistory can request history via CHATHISTORY LATEST/BEFORE/AFTER/AROUND/BETWEEN #channel <cursor> <limit> or list active conversations with CHATHISTORY TARGETS timestamp=<from> timestamp=<to> <limit>. Clients with draft/event-playback receive the full event timeline; otherwise only messages are returned.
  • Edit history — Edited messages retain a pointer to their original msgid. On CHATHISTORY replay, clients with draft/message-edit receive the +draft/edit tag so they can update their local buffer.
  • Read markers — MARKREAD timestamps are persisted per account in MariaDB and survive server restarts.
  • Metadata — METADATA key-value entries set on users and channels are persisted in MariaDB.

IRCv3 Support

Capability names follow the registry: features the specifications advertise with an ISUPPORT token (WHOX, UTF8ONLY, BOT, ACCOUNTEXTBAN) are not advertised as capabilities, and work for every client. Names rIRCd advertised before 1.4 are still accepted in CAP REQ so older clients keep working.

Capability / featureStatusNotes
capability-negotiationFullCAP LS/REQ/ACK/NAK/END, 302 multi-line
message-tagsFullParse & send tags; TAGMSG; msgid/server-time/account tags
Client-only tagsFullServer forwards +-prefixed tags on PRIVMSG/NOTICE/TAGMSG
server-timeFulltime tag on messages for capped clients
message-idsFullmsgid tag (with message-tags); unique id per message
batchFullNAMES and chathistory wrapped in BATCH
echo-messageFullPRIVMSG, NOTICE, TAGMSG echoed to sender when cap set
multi-prefixFullNAMES/WHO send all prefixes in rank order (^&@%+)
extended-joinFullJOIN #ch account :realname for clients with cap
account-tagFullaccount= tag on messages for capped clients
account-notifyFullACCOUNT on SASL login/quit to channel peers with cap
chghostFullSETHOST/SETUSER (oper-only); CHGHOST to channel peers with cap
setnameFullSETNAME command; broadcast to setname peers
away-notifyFullAWAY to channel peers with cap when user sets/unsets away
invite-notifyFullINVITE to channel members with cap when someone is invited
labeled-responseFullClient label tag echoed on all replies
standard-repliesFullFAIL for SETNAME, REDACT, UTF-8 errors
no-implicit-namesFullNo NAMES burst on JOIN when client has cap
userhost-in-namesFullNAMES (353) with full nick!user@host when client has cap
UTF8ONLYFullISUPPORT token (not a capability); non-UTF-8 rejected with FAIL … INVALID_UTF8
cap-notifyFullCAP NOTIFY with current cap list on REQ/ACK and END; dynamic CAP NEW/CAP DEL on REHASH
draft/extended-isupportFullISUPPORT command; 005 before registration
WHOXFullISUPPORT token (not a capability); WHO <target> %fields[,token] answered with 354 RPL_WHOSPCRPL for any client
bot-modeFullBOT=B ISUPPORT token (not a capability); umode +B, bot tag, RPL_WHOISBOT (335)
draft/oper-tagFulldraft/oper=<name> tag on messages from IRC operators, for clients with the cap
draft/message-redactionFullREDACT command; soft-delete in DB; CHATHISTORY replays REDACT events for client sync
draft/message-editFullPRIVMSG with +draft/edit=<msgid> tag; DB-backed ownership check; edit history replayed in CHATHISTORY
draft/reactFullTAGMSG with +draft/react=<emoji>; forwarded via client-only tag relay
draft/unreactFullTAGMSG with +draft/unreact=<emoji>; forwarded via client-only tag relay
typingFullTAGMSG with +typing=active/paused/done; forwarded via client-only tag relay
replyFullMessages with +reply=<msgid> tag forwarded as-is
ACCOUNTEXTBANFullISUPPORT token (not a capability); MODE +b ~a:account, JOIN 474 when banned by account
EXTBAN muteFullISUPPORT EXTBAN=~,OSajmnrt; MODE +b ~m:nick!*@* keeps someone from talking without keeping them out. Voice lifts it, and MODE +e ~m:mask excepts from it. MODE #chan +b with no mask reads the list without needing op, and RPL_BANLIST/RPL_EXCEPTLIST/RPL_INVITELIST name who set each entry and when
saslFullAUTHENTICATE PLAIN, SCRAM-SHA-256, and EXTERNAL (TLS client cert); 903/904; certfp auto-associated on PLAIN/SCRAM login
monitorFullMONITOR +/−/C/L/S; 730/731/732/733/734; on join/quit/nick
extended-monitorFullAWAY/ACCOUNT/CHGHOST/SETNAME forwarded for monitored nicks; nick!user@host masks (*/?) may be monitored as well as plain nicks
stsFullStrict Transport Security; advertised in CAP LS only when TLS is configured; duration=2592000
draft/channel-renameFullRENAME old new [reason]; op-only; fallback PART+JOIN for clients without cap
draft/chathistoryFullCHATHISTORY LATEST/BEFORE/AFTER/AROUND/BETWEEN/TARGETS for channels and direct conversations; BATCH chathistory; DB-backed; limit 200
draft/event-playbackFullJOIN/PART/QUIT/TOPIC/NICK events stored in DB and replayed in CHATHISTORY
draft/network-iconFullOptional ICON= ISUPPORT token; config network.icon
draft/read-markerFullMARKREAD target [timestamp]; per-account, persisted in MariaDB. A client with no account keeps its marker for the life of the connection only — a connection id does not come back tomorrow to read it
draft/metadata-2 / draft/metadata-3FullMETADATA GET/LIST/SET/CLEAR/SUB/UNSUB/SUBS/SYNC; key-value per user and channel. A person's keys are filed under their account, not their nick, so a profile follows the person whatever they are called and the next holder of a name never inherits an avatar or a display name that was never theirs. Somebody with no account has their keys filed under the nick, never written down, and dropped when the connection goes; logging in during registration files them under the account instead. Metadata set under a nick by an older version is moved onto its account at startup, and dropped if no account by that name exists. Both names of the same specification are advertised; replies to a -3 client come back in a metadata batch and subscription notices as RPL_KEYVALUE. before-connect lets a client set its own keys during registration; RPL_WHOISKEYVALUE (760) carries them in WHOIS. An invite-only or secret channel does not hand its metadata to non-members
STATUSMSGFullPRIVMSG/NOTICE to @#channel (ops+) or +#channel (voiced+); advertised in 005 STATUSMSG=@+
draft/account-registrationFullREGISTER * [email] password; logs the client in on success; optional email verification via VERIFY ([email] config)
draft/multilineFullBATCH draft/multiline; max-bytes=4096, max-lines=20; fallback for non-multiline clients
draft/pre-awayFullAWAY during registration; applied after NICK/USER complete
draft/channel-contextFull+draft/channel-context tag forwarded to channel members
draft/client-batchFullClient-originated BATCH types collected and relayed to recipients
CLIENTTAGDENYFullOptional 005 token; config server.client_tag_deny
WebIRCFullWEBIRC password gateway hostname ip; config [webirc]
WebSocketFullIRCv3 WebSocket transport; listen_ws/listen_wss config; subprotocol text.ircv3.net
draft/webpushFullWEBPUSH REGISTER/UNREGISTER; VAPID-signed, aes128gcm-encrypted pushes for DMs and highlights; VAPID= ISUPPORT token; [webpush] config
draft/filehostFullHTTPS file upload endpoint with HTTP Basic auth (same credentials as SASL PLAIN); reuses [tls] certs; FILEHOST= / draft/FILEHOST= ISUPPORT tokens; MIME-typed downloads; configurable max upload size

Roadmap

Features under consideration for future releases:

FeatureDescription
Offline direct messagesAccept a PRIVMSG to a registered user who is not connected, store it, and push it — today an offline nick still answers 401, and only channel mentions reach absent users
custom-account-nameLet an account be named something other than the current nick; needs channel op/voice lists to stop treating nicks and account names as interchangeable first

Standard IRC Commands

In addition to IRCv3 features, rIRCd implements the standard IRC command set:

CommandNumericsDescription
LIST321/322/323List channels. ELIST=CMNTU: >N/<N by user count, a glob name mask, !mask for the channels it does not match, C>N/C<N by how long ago the channel was created, T>N/T<N by how long ago the topic was set (both in minutes)
LUSERS251/252/254/255/265/266Server user/channel statistics
VERSION351Server version string
TIME391Server local time
INFO371/374Server info and uptime
LINKS364/365Linked servers (single-server: lists self)
STATS u242/219Server uptime
STATS o243/219IRC operator list
STATS k216/219Server bans in force, with time remaining
STATS m212/219How often each command has been used
WHOWAS314/312/369Historical nick info; the last 5 visits of each of the last 10,000 nicks in memory, 20 per nick in the database for 30 days
WHO mask352/315Supports glob masks (*, ?) against nick!user@host; respects +i invisible mode
HELP / HELPOP704/705/706Per-command help text
KNOCK710/711Request invite to an invite-only channel; notifies ops
KILL—Oper-only: forcibly disconnect a user; broadcasts QUIT to their channels
KLINE—Oper-only: KLINE [<seconds>] <mask> :<reason> — refuse connections matching a mask; existing ones are closed. The host may be a network in CIDR form (*@203.0.113.0/24, *@2001:db8::/32). Persisted in MariaDB
UNKLINE—Oper-only: remove a ban
DLINE—Oper-only: DLINE [<seconds>] <address|network> :<reason> — turn an address away the moment it connects, before the handshake, the blocklist lookup or a connection slot has been spent on it. CIDR for a network; nothing wider than a /8 (IPv4) or /16 (IPv6). Crosses links like KLINE; STATS d lists them
UNDLINE—Oper-only: lift a D-line
TESTMASK—Oper-only: TESTMASK <mask> — how many people a K-line on this mask would hit, here and on the rest of the network (724), before anybody sets it
SPAMFILTER—Oper-only (ban): patterns an operator would rather never see again; see Spam filters
SHUN—Oper-only (ban): SHUN [<seconds>] <mask> :<reason> — leave somebody connected and let nothing they say reach anybody. See Shuns
RESV—Oper-only (ban): RESV [<seconds>] <pattern> :<reason> — a name this network keeps for itself. #-patterns are channels, anything else nicks
UNRESV—Oper-only (ban): give a reserved name back
UNSHUN—Oper-only (ban): lift a shun
ELINE—Oper-only (ban): ELINE [<seconds>] <mask> :<reason> — say who the bans and the blocklist do not apply to. See Exemptions
UNELINE—Oper-only (ban): stop vouching for a mask
GROUP—Reserve the nick you are using for your account; GROUP -<nick> releases one, GROUP * lists them
SETEMAIL—SETEMAIL <current password> <new address> then SETEMAIL <code> — move your account to another address. See Looking after your own account
ACCOUNTINFO—ACCOUNTINFO [<account>] — what the server is holding about an account. Yours without asking; somebody else's needs the ban privilege. ACCINFO is the same command
NOEXPIRE—Oper-only (channels): keep an account or a channel out of [expiry]'s reach
MAP—The network as a tree with a user count per server (015/017)
STATS—STATS u uptime and STATS m command counts are for anybody; o (operator blocks), k (K-lines), d (D-lines), s (shuns), e (exemptions), y (connection classes — 218), l (what each connection has carried: send queue, messages and bytes each way, how long it has been open — 211) and t (what this server has been doing — 249) are for operators
TRACE—Oper-only: TRACE [<nick>] — the connections this server is holding (204/205) and the servers it is linked to (206), ending with 262. The class is how each one arrived: plain, tls or websocket
SAJOIN—Oper-only (channels): SAJOIN <nick> <#channel> — put somebody in a channel. The server invites them, so +b, +i, +k, +l and +j open; +O, +Z and +R still hold, because a forced join that broke a channel's promise would be the server lying on the operator's behalf. Somebody on another server is joined by that server at this one's request
SAPART—Oper-only (channels): SAPART <nick> <#channel> [:<reason>] — take somebody out of a channel; an ordinary PART, with the reason given
SAMODE—Oper-only (channels): SAMODE <#channel> <modes> [<args>] — set channel modes without holding ops there. Shown as the operator's own MODE; the operators are told it was done this way
DIE—Oper-only: shut the server down
ADMIN256/257/258/259Who runs this server ([server] admin_*)
GHOST—Close a stale session holding a nick your account owns, on this server or another one
WALLOPS—Oper-only: broadcast a message to all users with +w
MOTD375/372/376Send the message of the day
ISON303Check which nicks in a list are currently online
USERHOST302Return nick=+user@host info for up to 5 nicks

User Modes

ModeSet byDescription
+BUserBot mode — shown in WHOIS as a bot (RPL_WHOISBOT 335)
+iUserInvisible — hidden from WHO unless sharing a channel
+oServerIRC operator — set by successful OPER command
+rServerRegistered — set automatically on SASL login
+wUserReceives WALLOPS broadcasts from opers
+sOperServer notice mask — MODE <you> +s [+|-]<letters> chooses which server notices you hear (a accounts, b bans, c connections, f floods, k kills, l links, n nick changes, o OPER attempts, s server); -s clears it. HELP SNOMASK
+gCallerid — only people on your ACCEPT list may send you direct messages. A sender who is not is told once (716/717) and you are told who knocked (718), once a minute per knocker. ACCEPT <nick>, ACCEPT -<nick>, ACCEPT * to list

Channel Modes

ModeDescription
+oChannel operator
+vVoice (+)
+hHalf-op (%) — a moderator. May set the topic under +t, voice, ban, moderate, and throw out somebody below them, but may not hand out status at or above their own, and may not act on somebody standing level with them. Appointed by an operator: one who could appoint another could fill a channel with them
+bBan list — glob masks and the extended bans ~a: (account), ~r: (real name), ~j: (in another channel), ~S: (client certificate), ~O (operators), ~m: (mute rather than ban), ~n: (no nick change) and ~t: (lifts itself). The prefixes peel one at a time, so they stack: ~m:~r:*spam* mutes by real name, ~t:1h:~j:#raiders expires
+eBan exception list — exempt users bypass +b bans
+IInvite exception list — matching users bypass +i without explicit INVITE
+qQuiet list — silences matching users without kicking. Not the founder mode it is on some servers: that is +x here, because this list is persisted, takes timed bans and feeds +z, and was not worth breaking for a letter
+xFounder, shown as ^ — the person the channel belongs to. Given on join to whoever the registration names, so it cannot be taken by a MODE, and settable only by the founder or a server operator
+aAdmin, shown as & — somebody the founder has put above the operators. An appointment for the visit, like +h: it is not written down
+iInvite-only
+mModerated — only +v/+h/+o may speak
+nNo external messages
+sSecret channel — see What a secret channel gives away
+tTopic restricted to ops. Set on every new channel
+nNo messages from outside the channel. Set on every new channel
+kChannel key (password)
+lUser limit
+jJoin throttle, <joins>:<seconds> — no more than that many joins in that many seconds (480 past it). Whoever holds the channel, whoever it invited, and operators are let past, so a join flood slows the crowd without locking the owner out
MLOCKNot a mode but a lock on them: MLOCK #chan +nt-k says n and t stay on and k stays off, and a MODE from anybody but the founder (or a server operator) that would change one is refused (742). Kept with the channel, carried to every server. MLOCK #chan shows it, MLOCK #chan OFF clears it
+fFlood limit, <lines>:<seconds> — one line over it from one person and the server kicks them (Channel flood (limit is 5 lines in 10 seconds)); the line itself is not delivered. Ops, half-ops and operators are not the crowd it is for, and the founder cannot be kicked by it any more than by anyone else
+NNo nick changes while in the channel (447), unless you are an op or half-op there, or a server operator
+TNo NOTICEs to the channel from anybody who is not an op or half-op; dropped silently, as a refused NOTICE always is
+zOp-moderated: what somebody who may not speak (+m, +M, +q, a mute) says is delivered to the ops and half-ops as a message to @#channel instead of being refused — moderation the moderators can see
+OOperators only: nobody else may join (520). Only a server operator may set it
+LOverflow channel: when the channel is full (+l), somebody joining is told where they are being sent (470) and joins #overflow instead. One hop only
+b ~t:…A timed ban: +b ~t:30m:nick!*@* lifts itself after 30 minutes — s, m, h, d, or a bare number of minutes, up to a year. The server removes it with a MODE -b everybody sees, on every server. The same on +q, and around another extban (~t:1h:~a:account)
+b ~r:…A ban on the real name rather than the hostmask: +b ~r:*seedy_marketing*. A glob, because a real name is a sentence — and since a mode parameter cannot hold a space, _ stands for one on both sides. Somebody who gave no real name is matched only by a pattern that matches nothing in particular
+b ~j:#chanA ban on being somewhere else: +b ~j:#raiders keeps out whoever is in #raiders at the moment they try to come in
+b ~Z:…There is no such type, and a mask naming one is refused with 696 rather than kept. An extended ban whose type this server does not have falls through to being matched as a plain glob — a ban on somebody literally called ~Z:spammer, which is nobody — and it looks exactly like a ban that works. The same goes for a type given nothing to work with (~a:), ~O given something, ~j: given a name that is not a channel, and a mask wrapped in itself past any sense. Exceptions and invite exceptions are checked the same way: one that matches nobody is worse than a ban that does
+b ~S:…A ban on the TLS client certificate: ~S:* is everybody who brought one, ~S:3b8f* a particular one. Mostly useful as +e ~S:* — anybody who can prove who they are is excepted
+b ~n:…Keeps one person's name still: they may talk, but not change nick while in the channel (447). The one-person version of +N, lifted by +e ~n:mask. Not a ban on coming in
+b ~OMatches the server's operators. Written as a ban it is legal and does nothing useful; written as +e ~O it excepts them from what the room bans
+RRegistered users only — unregistered users cannot join or speak
+MOnly registered users may speak; anybody may join. Somebody given a voice or ops may speak regardless, as with +m. The anti-spam mode for a channel that wants to stay open to lurkers
+ZTLS only — a connection not over TLS cannot join, and the mode cannot be set while anybody in the channel is not on TLS (490). What is said in a +Z channel has never crossed a wire in the clear on any hop this server controls
+cStrip mIRC colour and formatting codes from messages
+CBlock CTCP messages (including /me actions)

What it says it does

ISUPPORT is a promise. A client that reads NICKLEN=32 will not offer a longer one; one that reads TOPICLEN=307 will not warn somebody their topic is about to be cut. Each of those numbers lives in exactly one place now — the token the server sends and the code that enforces it read the same constant — because they used to be written twice, which is how CHANLIMIT came to advertise fifty while the configuration said something else.

tests/smoke/test_isupport.py holds the server to it. Every expectation in that suite is read out of the server's own ISUPPORT rather than written down, so it does not check that the numbers are any particular value: it checks that the numbers the server gives out are the numbers it keeps. A nick one character over the advertised length is refused, a topic fifty over is cut to exactly it, a list takes the number it promises and refuses the next, and changing a configured limit changes both the token and the behaviour.

Performance

Measured rather than claimed. tests/smoke/throughput.py <receivers> <senders> puts every client in one channel, so each message a sender posts is delivered to every receiver, and reads the server's own CPU out of /proc around the burst alone — so the figures are steady-state delivery, not the cost of setting the connections up.

tests/smoke/run.sh --stop
SMOKE_BIN=$PWD/target/release/rircd tests/smoke/run.sh --serve-only
SMOKE_SERVER_PID=$(pgrep -x rircd) python3 tests/smoke/throughput.py 500 5

SMOKE_BIN matters: the suites run the debug build because they are about behaviour, and a debug build spends most of its time somewhere production never goes. On a quiet sixteen-core machine over loopback, 500 receivers in one channel and 5 senders:

Deliveries~126,000 a second
One message to all 500p50 9.7 ms, p99 19.2 ms
Server CPU per delivery~21 µs — about 9 µs its own work, about 12 µs asking the kernel

The second half of that is the interesting one. Every message is written to every recipient as it arrives rather than being held back to fill a buffer, so a delivery costs roughly one task wakeup and one write on the recipient's socket. That is the price of a chat server that answers immediately, and it is where the time goes: the server's own work — matching, tagging, copying — is the smaller half, and the fan-out loop builds one copy per set of negotiated capabilities rather than one per person so that it stays that way.

Testing

cargo test runs the unit and integration tests: message formatting, RFC 8291 encryption vectors, capability gating, verification-code rules, and fuzzers that throw arbitrary bytes at the parser and at the link protocol.

End-to-end, against a real server with a throwaway MariaDB and an SMTP sink behind it:

tests/smoke/run.sh                  # every suite, then tear down
tests/smoke/run.sh --keep           # leave it up for manual poking
tests/smoke/run-link.sh             # two servers, linked
tests/smoke/run-link.sh --tls       # the same, over an encrypted link

And against progval/irctest, which is the suite the other IRC servers are measured with:

tests/smoke/run.sh --serve-only     # it needs the database
tests/irctest/run.sh

Where that leaves things, on the same checkout with the same markers: rIRCd passes 544 of 557 with nothing failing, and four more under IRCTEST_RIRCD_CASEMAPPING=rfc1459 — which this server implements and the default run therefore skips. The six that never run are two for non-UTF-8 messages, refused on purpose, and four WHO tests irctest marks "not consistently implemented" and skips for everybody. For something to measure against, Ergo 2.19.1 on the same machine passes 527 and skips 26.

Two of the suites are about being attacked rather than being correct: test_hostile.py is a client that is not trying to be a client, and test_link_hostile.py is a peer that lies about which servers it speaks for. And soak.py runs for hours against a linked pair, watching for memory or open files that grow when they should not.

See tests/smoke/README.md and tests/irctest/README.md.


License

BSD 3-Clause. See LICENSE.

irc
rust
server

karazajac/rIRCd

A Rust-based IRC server with bleeding edge capabilities.

Rust

0

174 commits

updated Sep 19, 2026

See the code

README

rIRCd

A bleeding-edge IRC server in Rust, following IRCv3 specifications.

Requirements

  • Rust (stable, 2021 edition or later)
  • MariaDB or MySQL server

Initial Setup

1. Set up the database

Create a database and user in MariaDB:

CREATE DATABASE rircdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'rirc'@'localhost' IDENTIFIED BY 'your-password';
GRANT ALL PRIVILEGES ON rircdb.* TO 'rirc'@'localhost' WITH GRANT OPTION;
FLUSH PRIVILEGES;

The database schema (tables for users, channels, channel history, etc.) is created automatically on first startup — no SQL migration files to run.

2. Install rIRCd

cargo build --release
sudo cp target/release/rircd /usr/local/bin/

3. Run the interactive setup

sudo rircd init

This starts an interactive prompt that asks for:

  • Server hostname and network name
  • Plain-text port (default: 6667) and optionally a TLS port with cert/key paths
  • Database credentials (host, port, name, user, password)
  • Message of the day
  • Optionally, email verification for account registration (SMTP host, port, from address)
  • Optionally, an IRC operator account (password is bcrypt-hashed automatically)

A config.toml is written to /etc/rIRCd/ with all your answers filled in. If the file already exists you are asked before overwriting.

Example session:

rIRCd interactive setup
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Press Enter to accept the [default] value.

[Server]
  Server hostname [irc.example.com]: irc.mynetwork.org
  Network name [rIRCd]: MyNet
  Plain-text IRC port [6667]:
  Message of the day [Welcome to rIRCd!]: Welcome to MyNet!

[TLS]
  Enable TLS listener? [y/N]: y
  TLS port [6697]:
  Path to certificate (PEM) [/etc/rIRCd/cert.pem]:
  Path to private key (PEM) [/etc/rIRCd/key.pem]:

[Database]
  (rIRCd requires MariaDB/MySQL for user accounts and channel history.)
  Database host [localhost]:
  Database port [3306]:
  Database name [rircdb]:
  Database user [rirc]:
  Database password:

[Email verification]
  (Requires accounts registered with REGISTER to confirm an address with VERIFY.)
  Enable email verification? [y/N]:

[IRC Operator]
  Create an IRC operator account? [Y/n]:
  Operator name [admin]:
  Operator password:
  Operator password (confirm):

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Config written to /etc/rIRCd/config.toml
The database schema is created automatically on first startup.

Start the server with:  rircd run

You can always hand-edit /etc/rIRCd/config.toml afterwards — see Configuration Reference below.

4. Start the server

sudo rircd run

Connect with any IRC client to localhost:6667 (or whatever port you configured).


Configuration Reference

The only file rIRCd needs is /etc/rIRCd/config.toml. All user accounts, channels, and message history are stored in MariaDB.

[server]

KeyDefaultDescription
namerIRCd.localServer hostname shown to clients
casemappingasciiWhich names count as the same name. ascii folds A–Z and nothing else, and is what a network started today should use. rfc1459 also folds []\~ onto `{}
listen[":6667"]Plain-text listener addresses
listen_tls[]TLS listener addresses (requires [tls])
listen_ws[]WebSocket listener addresses (e.g. [":7667"])
listen_wss[]WebSocket-over-TLS listener addresses (requires [tls])
motd"Welcome to rIRCd!"Message of the day (inline text, multiline OK)
registration_timeout_secs60Time allowed to complete NICK/USER before disconnect
ping_timeout_secs90How long to wait for PONG before sending next PING
disconnect_timeout_secs150Time after missed PONG before disconnecting client
nick_protectiontrueReserve a registered nick for its account; others get 433
trusted_proxies[]Addresses whose X-Forwarded-For is believed, for the WebSocket listeners and the file host alike. Both usually sit behind a reverse proxy, and the header is how the proxy says who the client is — but it is only a header, and anybody can send one. Believed from nobody by default, so the connecting address is used. The last entry is taken, not the first: a proxy appends what it saw, so a client that sends a header of its own pushes its lie to the left. If you serve WebSockets or the file host behind nginx or similar, add the proxy's address here or every client will look like the proxy — which for the file host means one failed-login budget for everybody who came through that door
channel_creationanyoneWho may bring a new channel into being: anyone, accounts or opers. Only creating is gated, never joining one that already exists. accounts is the useful one — a channel made by somebody not logged in has no founder and never gets one, so requiring an account makes every channel owned from its first moment
admin_name / admin_location / admin_email(unset)Shown by ADMIN (256–259)
client_tag_deny(unset)List of client-only tags to drop (e.g. ["+typing"] or ["*"] to drop all)
cloak_key(unset)If set, connecting clients receive an HMAC-SHA256-based virtual host cloak (e.g. "mysecret")
descriptionrIRCd v<version>One-line description of this server, shown by LINKS
register_before_connecttrueAllow REGISTER before the handshake finishes; advertised as before-connect
multiclientfalseLet one account hold several connections at once — a desktop and a phone, say. They share a nick and a single place in every channel; anything addressed to the user reaches all of them, while the answer to a command goes back to the connection that sent it, and each connection sees only the message tags it negotiated. Needs SASL: a connection joins an account it has proved it belongs to, and no other
persistent_sessionsfalseTreat an account as one continuing session: logging in takes its nick back from an earlier session and rejoins that session's channels. Off by default — it disconnects the earlier session, which is what someone reconnecting after a dropped link wants and what someone with two clients open does not
password(unset)A password every connection must send with PASS before registering. Stored in the clear on purpose: it is shared with everyone allowed on the server, so it is a door key rather than a secret about any one person. A wrong or missing one gets 464 and the link is closed

Two or more rIRCd servers form one network. The protocol, and why it is not TS6, is in docs/server-linking.md.

[server]
sid = "1AA"                      # this server's identity on the network
listen_links_tls = ["0.0.0.0:7000"]  # server traffic never shares a client port

[[links]]
name = "irc2.example.org"
sid = "2BB"
host = "10.0.0.2"
port = 7000
send_password = "what we send"
receive_password = "what we expect"
tls = true
fingerprint = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
autoconnect = true
KeyDefaultDescription
sid(derived from the name)Three characters, a digit then two alphanumerics, unique on the network. Derived from the server name when unset, which is a coin toss for a network — set it before linking
listen_links[]Addresses to accept links on. A client that reaches one gets nothing, and a server that reaches a client port is treated as a client
listen_links_tls[]The same, encrypted with the certificate in [tls]. A link carries every private message that crosses it, so this is what a link between two machines should be
tlsfalseDial this peer over TLS. Needs a fingerprint, and needs [tls] set on this server too, because the peer pins this one in the same way
fingerprint(unset)SHA-256 of the peer's certificate, as openssl x509 -in cert.pem -noout -sha256 -fingerprint prints it. This is what says the far end is the server meant rather than whoever answered; there is no list of certificate authorities here, which is also why a self-signed certificate is as good as any other. A link told which certificate to expect refuses a peer that presents none, so the plaintext port is not a way around it
send_password / receive_password—Separate on purpose: each direction has its own secret, so one leaked configuration does not let the holder link both ways
autoconnectfalseKeep the link up, retrying with a widening delay

An operator with the links privilege can also shape the network by hand: CONNECT <server> dials a [[links]] block now rather than waiting for its next retry, and SQUIT <server> [:<reason>] drops the link to a directly attached server, telling it why first so the far end logs a decision rather than a failure. A server behind a peer is that peer's to drop. Both are announced to every operator, and a link that autoconnect keeps up comes back on its own after a SQUIT — set autoconnect = false and REHASH first if it is meant to stay down.

A link carries everything two servers need to agree on: who is connected, what they are called, which channels they are in and what is said in them — and who those channels belong to. Ownership is carried as accounts rather than nicks, because it has to mean the same person at both ends, and it is merged rather than replaced: two servers that each learned part of the truth end up holding all of it, and where they disagree about a founder the older channel keeps its own. A channel nobody is standing in is bursted too when somebody owns it, so a server joining the network learns whose it is rather than finding out from whoever walks in next.

A link also carries the two things that need an answer rather than an announcement — a WHOIS asks the server somebody is on how long they have been quiet, and a GHOST asks the server a stale session is on to close it. What a link is allowed to say, and what it is not, is in docs/server-linking.md.

tests/smoke/run-link.sh brings up two servers with their own databases on their own ports, links them, and checks what each one knows about the other. --tls runs the same checks over an encrypted link, with a throwaway certificate for each side.

Serving a hidden service

Behind a Tor onion service every client reaches the server from 127.0.0.1. max_connections_per_ip then reads as "this many people may use the server through Tor at once", which is not a rule anybody meant to write — and it cannot be fixed by counting more carefully, because the addresses genuinely are all the same. There is nothing to tell apart.

Give the hidden service a listener of its own and name it:

[server]
listen = ["0.0.0.0:6667", "127.0.0.1:6668"]   # 6668 is the one Tor forwards to

[limits]
max_connections_per_ip = 16                   # still true of the public port
shared_address_listeners = ["127.0.0.1:6668"]
max_clients_behind_one_address = 256

The public port keeps the per-address limit, which is the stronger rule. The onion's port is capped as a whole: a weaker promise, and the strongest one available, because it bounds what the door can let through without pretending to know who is coming through it.

Worth knowing: a host ban lands on every onion client at once, since they are one address, and the cost of a failed login is counted per address too, so one person guessing passwords makes the others wait. WEBIRC does not help here — the connection's slot is claimed before the first line is read, so a gateway that could say who the client really is says it too late to matter.

[network]

KeyDefaultDescription
namerIRCdNetwork name (shown in 005 NETWORK)
icon(unset)URL to a network icon image (advertised as ICON= in ISUPPORT; draft/network-icon)

[database]

KeyDefaultDescription
hostlocalhostMariaDB/MySQL host
port3306MariaDB/MySQL port
user(empty)Database username
password(empty)Database password
databaserircdbDatabase name

[tls]

Optional. Both fields must be set to enable TLS listeners. REHASH reloads the certificate in place, so a renewal does not need a restart.

KeyDescription
certPath to PEM certificate file
keyPath to PEM private key file
client_certsIf true, request TLS client certificates for SASL EXTERNAL (default false)

Example:

[server]
listen_tls = [":6697"]

[tls]
cert = "/etc/rIRCd/cert.pem"
key  = "/etc/rIRCd/key.pem"

[limits]

KeyDefaultDescription
max_channels_per_client50Max channels a single client may join
max_channels_per_account30How many channels one account may have registered to it at once; 0 for no limit. Creating a channel while logged in registers it, and a registration is a row that outlives the visit — joining and leaving in a loop makes hundreds an hour through the ordinary flood allowance, and they stay until [expiry] takes them, which an operator may never have turned on. Past this many the channel is still made and the person still gets @ in it; it simply is not written down as theirs, and they are told so and how to make room
max_connections_per_ip16Connections allowed from one address at a time; 0 for no limit. An IPv6 address counts by its /64 — the smallest allocation anybody is given — so a client that picks a fresh address per connection is still one client. Read at startup, not on rehash
max_connections_per_ip_per_minute30New connections one address may make in a minute; 0 for no limit. The concurrent limit never sees a client that connects and hangs up in a loop, and each of those costs a handshake. Read at startup
max_registrations_per_ip30Registrations one address may make in ten minutes; 0 for no limit. Each is a hash, a row, and with [email] a message to an address the client chose. Thirty in ten minutes is an office behind one NAT on its first day; a hundred is a script. Read at each attempt, so a rehash changes it
shared_address_listeners[]Listeners where every client arrives from the same address — a Tor hidden service, or anything behind a local proxy. The per-address limit cannot mean anything there, so connections on these are counted against the listener instead. Must name a listen, listen_tls, listen_ws or listen_wss address exactly; the server refuses to start if it does not, because a typo would silently leave the listener capped at max_connections_per_ip. Read at startup, not on rehash
max_clients_behind_one_address256The cap that stands in for the per-address one on those listeners; 0 leaves only max_clients
max_remote_users250000Users this server will hold on behalf of other servers; 0 for no limit. A link is trusted with what it says about its users, not with how many of them there is room for: past this, further introductions are refused and logged, and the link stays up
max_servers512Servers this one will know about, direct peers included; 0 for no limit. No network has more than a few hundred
max_clients10000Connections allowed in total; 0 for no limit. Every other limit is per address, and anybody with IPv6 has a great many /64s to rent, so this is the one they meet
max_line_length512Longest message body accepted, before tags; advertised as LINELEN
flood_burst10Commands a client may send back to back before being throttled
flood_rate1Commands per second the flood allowance refills at
min_password_length6Shortest password REGISTER accepts; advertised in draft/account-registration
max_targets4Recipients one PRIVMSG, NOTICE, TAGMSG or KICK may name at once; advertised as TARGMAX

[[opers]]

Define IRC operators. Multiple [[opers]] blocks are allowed.

[[opers]]
name = "admin"
hostmask = "*"            # optional; restrict by host
password_hash = "$2a$..." # generate with: rircd genpasswd

[[opers]]
name = "helper"
password_hash = "$2a$..."
privileges = ["kill", "ban"]   # omit for all privileges

privileges limits what an operator may do: kill, ban (KLINE/UNKLINE, DLINE/UNDLINE), rehash, die, sethost, wallops, channels (CHANOWN on a channel that is not theirs), links (CONNECT and SQUIT). Omitting the key keeps the previous behaviour, where every

An operator block's hostmask (user@host or nick!user@host, judged on the real address, not the cloak) is where that operator is allowed to be; OPER from anywhere else is refused with 491. An operator block may ask for more than a password. require_tls = true refuses OPER over a connection that is not TLS, so the password never crosses a wire in the clear. certfp = "SHA256:…" (the fingerprint as openssl x509 -noout -sha256 -fingerprint prints it, colons optional) refuses OPER unless that client certificate was presented — as a second factor when password_hash is set too, and on its own when it is left empty, in which case OPER <name> with no password is the whole ceremony.

[[opers]]
name = "kara"
certfp = "3B:8F:…:A1"
require_tls = true
privileges = ["kill", "ban", "channels"]

kill also covers SANICK <nick> <newnick>, the milder cousin of KILL: somebody sitting on a name they should not have is moved off it rather than off the network, told who did it, and the network sees an ordinary nick change — a user on another server is renamed by that server at this one's request. operator may do everything — so this only narrows operators who were already narrowed, and channels has to be listed for them to move a channel.

[filehost]

Optional, and how somebody shares a picture here. There is no upload bot and no third-party host: the server keeps the file itself. A client reads the FILEHOST token out of ISUPPORT, POSTs the file there over HTTPS with the same account credentials it uses for SASL — HTTP Basic, because that is what the extension says — and is handed back a URL in the Location header to paste into the channel. Anybody with the link can fetch it; only somebody with an account can put one there.

KeyDefaultDescription
listen0.0.0.0:8080Where the file host listens
public_url(required)The base URL people will actually reach it on (e.g. https://irc.example.com/uploads). Its path is where the routes are mounted, so a reverse proxy in front needs no special case, and an https:// one makes the server serve it with the certificate from [tls]
upload_dir/var/lib/rircd/uploadsWhere the files go
max_size52428800 (50 MiB)The largest file it will take; anything over gets 413
max_uploads_per_hour60How many files one account may put there in an hour; 0 for as many as it likes, which on a server anybody can register on means as much disk as it likes. Over it is 429 with a Retry-After

The credentials are the account's, so guessing at them here costs what guessing at them over IRC costs: the same allowance is spent per address, and an address that keeps getting it wrong is answered 429 with a Retry-After without a password being checked at all. Otherwise every bit of the care the IRC side takes over passwords would be one HTTP request away from being beside the point.

A file is stored under a name of the server's choosing — a UUID, keeping only a sanitised extension — so nothing an uploader writes becomes a path, and nothing they upload can overwrite anything. tests/smoke/test_filehost.py covers the lot, including the links that try to leave the directory.

Two doors to one file host

A network reachable both by name and as a hidden service has two front doors to one file host. A client that arrived through the onion service and is handed the public name has been asked to leave Tor to fetch a file — which is the one thing it was trying not to do — and its client may simply refuse, so sharing a file does not work for it at all.

[[filehost.alternates]]
listener = "127.0.0.1:6668"                  # the port the onion service maps to
public_url = "http://xyz…onion/uploads"

The FILEHOST token in ISUPPORT is then chosen per client, by the listener it arrived on. One upload directory, one set of routes, two names for it: the onion service points at the same file host, so the files are the same either way.

The link handed back matches too. Behind a proxy every door arrives on the same socket, so the file host cannot tell them apart the way the IRC side can — what tells them apart is the name the client dialled, which the proxy passes on in Host. That header is not used as a URL: it only chooses among the ones written here, so the worst a made-up one can do is pick another of your own doors. Point the proxy at the file host with the client's Host preserved, which is the default in Caddy and nginx's proxy_set_header Host $host.

Keep the path the same in both (/uploads here) and change only the scheme and host. Routes are mounted under the path the primary public_url names, so an alternate with a different one would hand out links that answer nowhere — the server says so at startup rather than leaving you to find out.

What this does not do is rewrite links people paste in chat. A link is text once it is sent, so a file shared by a Tor user is an onion link to everybody, and one shared by a clearnet user is a clearnet link — including to the people on the onion service, who will have to decide for themselves whether to follow it. The server chooses which door each client is told to use, and gives them a link on that door; it does not edit what anybody says.

Behind a reverse proxy, set [server] trusted_proxies to the proxy's address. Without it every request looks like it came from the proxy, so everybody who came through that door shares one failed-login budget — and whoever is guessing spends the allowance of every real user behind it. That is worth more than it sounds on a network reachable over Tor, where the door those users arrive by is the same one.

That last case has no header to fix it: a hidden service hands over no per-user address, so those clients share one whatever is read. So a failed check is charged twice — once to the address and once to the account being tried — and the wait is whichever has earned more. An account under guessing is slowed wherever the guessing comes from, and one guesser on a shared address no longer has to be paid for by everybody else on it. A right answer gives both back.

Nothing reclaims that disk on its own. [expiry] uploads_days is how you say how long a file is kept; without it the server says so at startup, because a directory that only ever grows is worth knowing about before it matters. The sweep touches regular files in the upload directory and nothing else — not a symbolic link, not a subdirectory, not a dotfile.

Example:

[filehost]
listen = "0.0.0.0:8080"
public_url = "https://irc.example.com/uploads"
upload_dir = "/var/lib/rircd/uploads"
max_size = 52428800

[email]

Optional. Turns on email verification for account registration (draft/account-registration VERIFY). With this section present, REGISTER requires a real address, the account is stored unusable, and a code is mailed to the address; the account only becomes usable once VERIFY confirms the code. The email-required token is added to the advertised draft/account-registration capability so clients know to ask for an address.

Mail is sent over SMTP with rustls — no system TLS libraries needed.

KeyDefaultDescription
smtp_host(required)SMTP server hostname
smtp_port587SMTP port (587 STARTTLS, 465 implicit TLS, 25 plain)
smtp_user(unset)SMTP username; omit for an unauthenticated relay
smtp_password(unset)SMTP password
encryptionstarttlsstarttls, tls (implicit) or none
from(required)From address, e.g. ExampleNet <noreply@example.com>
subjectYour IRC account verification codeSubject line
code_expiry_secs86400How long a code stays valid
reset_subjectYour IRC password reset codeSubject line of a RESETPASS mail. Reset codes are good for fifteen minutes regardless of code_expiry_secs: they arrive by mail that anybody holding the inbox can use
mail_gap_secs900The least time between two messages to one address, whoever asks and by whichever of REGISTER or RESETPASS. Asking for a message is free and the address is the client's to choose, so the address itself gets a say. The gap starts only when a message actually goes out, so a registration refused for its password does not cost the person their real attempt
[email]
smtp_host = "smtp.example.com"
smtp_port = 587
encryption = "starttls"
from = "ExampleNet <noreply@example.com>"
smtp_user = "noreply@example.com"
smtp_password = "s3cr3t"
code_expiry_secs = 86400

[webpush]

Optional. Enables Web Push notifications (draft/webpush, RFC 8030/8291/8292) so mobile and web clients can be woken for messages while their app is asleep. When configured, the draft/webpush capability is advertised and clients register endpoints with WEBPUSH REGISTER.

A VAPID key pair is generated on first start and stored in key_file (mode 0600). Its public key is advertised to capable clients in the VAPID= ISUPPORT token and used to sign every push request — back this file up: replacing it invalidates every existing subscription.

KeyDefaultDescription
contact(required)VAPID sub claim — a mailto: or https: URL identifying the operator
key_file/etc/rIRCd/vapid.keyWhere the VAPID private key is stored. Generated on first start if missing; an existing key is read in whichever shape it came in — the bare 32-byte scalar this server writes, or the PKCS#8 that web-push, py_vapid and the online generators hand out, base64 or base64url, PEM wrapper or not. They are the same key either way, and the server says which public key it read at startup so it can be checked against whatever the web app was built with
max_subscriptions_per_account5Endpoints one account may have registered at once
ttl_secs3600TTL requested of the push service per notification
max_failures5Consecutive delivery failures before a subscription is dropped
allow_private_endpointsfalsePermit endpoints resolving to loopback/private addresses (testing only)

Bringing your own key: put it in key_file before the first start and the server uses it — no conversion, whatever your generator produced. It logs the public key it derived, which is the one the browser needs in applicationServerKey; if that does not match what the web app has, the subscriptions it creates cannot be decrypted by this server. Keep the file to the server and treat it the way you treat the TLS key: anything holding it can send notifications as this network.

[webpush]
contact = "mailto:admin@example.com"
key_file = "/etc/rIRCd/vapid.key"
max_subscriptions_per_account = 5
ttl_secs = 3600

Subscriptions belong to an account, so a client must be logged in (SASL or REGISTER) before WEBPUSH REGISTER; they are stored in MariaDB and survive restarts.

WEBPUSH REGISTER <endpoint> p256dh=<key>;auth=<secret>
WEBPUSH UNREGISTER <endpoint>

Endpoints must use https and must not resolve to loopback, private, link-local or carrier-grade-NAT addresses. A notification carries exactly one IRC message (with msgid, time and account tags), encrypted with aes128gcm per RFC 8291, and is sent for:

  • direct PRIVMSG/NOTICE to the user, and
  • channel PRIVMSG/NOTICE that mention their nick.

Notifications also reach users who are not connected: when a registered user joins a channel, that membership is remembered, so a mention still pushes to them while they are away — and the message itself is waiting in channel history when they return. Leaving the channel, or being kicked from it, ends that.

Endpoints the push service reports as gone (404/410), or that fail max_failures times in a row, are removed automatically. Clients should re-send an identical WEBPUSH REGISTER periodically (daily is typical) to keep a subscription fresh.

[dnsbl]

Ask a DNS blocklist about every public address that connects. The addresses that open connections to IRC servers by the thousand are the same ones that do it to everybody else, and somebody has already written them down.

[dnsbl]
zones = ["dnsbl.dronebl.org"]   # the first that answers decides
action = "reject"               # or "warn": let them in, and log
timeout_secs = 2
cache_secs = 600

Each address is asked about once and the answer remembered. A lookup that times out counts as not listed — a resolver outage must never lock everybody out — and addresses that are not public are never asked about, nor is anything arriving on a shared_address_listeners port, since the address there is everybody's. A listed address is turned away before its first line with ERROR :Closing link: Your address is listed in <zone>.

[webirc]

Optional. Lets a gateway — a web client, a bouncer, a Tor front end — say what address a connection really came from, so the person behind it is judged on their own address rather than the gateway's.

[webirc]
password = "gateway-secret"
hosts = ["203.0.113.5", "10.0.0.0/8"]   # only these may say it
KeyDefaultMeaning
password(required)What the gateway sends as the first parameter of WEBIRC. Compared in constant time
hosts[] (nobody)The addresses allowed to use it, exactly or as a network in CIDR form. Empty means nobody, and the server says so at startup

That address is the one fact everything else rests on: the K-lines and D-lines, a channel ban on a host, the blocklist, the cloak, and the budget a failed login spends. A password on its own guarded it, and a password is handed to whoever runs the gateway, written into their configuration, and read by somebody else eventually — so it is guarded the way trusted_proxies guards X-Forwarded-For: believed only from an address you named.

Empty means nobody rather than everybody, because a list that means everybody when left blank is a list nobody fills in. A connection from anywhere else is refused with the same answer a wrong password gets, and told nothing about whether it guessed right.

Full example config

[server]
name = "irc.example.com"
listen = [":6667"]
listen_tls = [":6697"]
motd = """
Welcome to Example IRC!
Have fun and be nice.
"""
registration_timeout_secs = 60
ping_timeout_secs = 90
disconnect_timeout_secs = 150

[network]
name = "ExampleNet"

[database]
host = "localhost"
port = 3306
user = "rirc"
password = "s3cr3t"
database = "rircdb"

[tls]
cert = "/etc/rIRCd/cert.pem"
key  = "/etc/rIRCd/key.pem"

[limits]
max_channels_per_client = 50

[[opers]]
name = "admin"
hostmask = "*"
password_hash = "$2a$12$..."

[filehost]
listen = "0.0.0.0:8080"
public_url = "https://irc.example.com/uploads"
upload_dir = "/var/lib/rircd/uploads"
max_size = 52428800

[email]
smtp_host = "smtp.example.com"
smtp_port = 587
encryption = "starttls"
from = "ExampleNet <noreply@example.com>"
smtp_user = "noreply@example.com"
smtp_password = "s3cr3t"

[webpush]
contact = "mailto:admin@example.com"
key_file = "/etc/rIRCd/vapid.key"

Reloading

REHASH from an operator and SIGHUP from outside do the same thing: read the configuration again and put it in place without dropping anybody. The certificate is re-read too, which is the point of it — renewals happen on somebody else's schedule, and restarting to pick one up would disconnect everyone.

systemctl reload rircd        # the unit file sends SIGHUP
kill -HUP "$(cat /etc/rIRCd/rircd.pid)"

For Let's Encrypt, that is the deploy hook:

certbot renew --deploy-hook 'systemctl reload rircd'

What survives the reload is everything a connection is holding: the database pool, the history writer, the servers already linked, and the Web Push key — rotating that last one would invalidate every push subscription registered under it. A configuration that will not load leaves the running one alone and says why in the log.

[[classes]]

[limits] says what a client may do, which is the right answer only when every client is the same one. A gateway carrying two hundred people from one address, a bot that must not be pinged out while it thinks, and somebody on a phone are not the same thing, and a server with one set of numbers is a server tuned for whichever of them it would rather lose.

[[classes]]
name = "gateway"
hosts = ["10.0.0.5", "192.168.0.0/16"]
max_per_ip = 0          # this address may bring as many as it likes
max_clients = 500       # but no more than this many at once, all told
ping_secs = 300
sendq = 8192

[[classes]]
name = "everybody"      # no hosts: the one everybody else falls into
max_per_ip = 5
KeyMeaning
hostsAddresses in this class: globs (10.0.0.*, *.example.org) or networks in CIDR form. Empty matches everybody, so that class goes last
max_clientsHow many connections this class may hold at once. The one over it is turned away with This class of connection is full
max_per_ipHow many one address may hold within this class; 0 for as many as it likes
ping_secsHow long a connection here may be quiet before it is pinged
flood_burst, flood_rateCommands it may send back to back, and how fast the allowance refills
sendqMessages that may be queued for it before it is dropped for not reading. A gateway needs a deeper queue than a phone

A connection lands in the first class whose hosts it matches. Anything a class leaves unset falls through to [limits] and [server], so adding a class changes nothing except what it says. STATS y lists the classes with how many connections are in each, and TRACE calls each connection by its class — falling back to how it arrived (plain, tls, websocket) when no class named it.

Classes are read when the server starts, not on REHASH: the part of the server that accepts connections took its copy at startup. STATS y reports that copy rather than the file, so it never claims a class that is not in force.

[expiry]

What a services package would call nick and channel expiry. Off unless set: this is a policy about other people's names and rooms, and a server with real people on it should choose it rather than be handed one.

[expiry]
accounts_days = 365   # erase an account nobody has logged in to for a year
channels_days = 90    # give up a registration nobody holding it has visited for 90 days
uploads_days = 30     # let go of a shared file after a month
KeyDefaultMeaning
accounts_days0 (never)An account nobody has logged in to for this long is erased: its nick is free again, its grouped nicks with it, its profile and read markers go with it, and the channels it founded are left without a founder — the people in them are told, as with DROPACCOUNT
channels_days0 (never)A registration nobody who holds the channel — founder or standing operator — has been in for this long is given up. The channel keeps its topic and its modes; it is simply nobody's, and the next person in gets @ the way they would in any fresh channel
uploads_days0 (never)A file shared through [filehost] is let go of this long after it was uploaded, and its link becomes a 404. Kept by age rather than by whether anybody still wants it, because nothing here knows who has a link — so this is really a statement of how long a link is good for, which is a thing people can be told in advance. Unlike "until the disk fills", which is what leaving it unset means

An operator can keep a name or a room out of expiry's reach with NOEXPIRE <account|#channel> ON (OFF puts it back; without either it says which it is). What a services package calls the same thing.

"Unseen" is measured the way a person would measure it. A login is use, and so is a holder standing in the room — including one who connected months ago and never left: the sweep looks at who is connected before it looks at the clock, and refreshes the clock for them rather than expiring them. It runs a couple of minutes after startup, once an hour after that, and on every REHASH, so a changed policy can be seen to work. The clock starts the first time this version runs, so nobody expires on the day of an upgrade. Operators are told what each sweep let go of, and what it let go of is announced across links the way DROPACCOUNT and CHANACCESS withdrawals are.

CLI Commands

CommandDescription
rircd init [--dir /etc/rIRCd]Create config directory with a default config.toml
rircd [--config /etc/rIRCd/config.toml] runStart the server; connects to DB, inits schema, writes PID file
rircd [--config /etc/rIRCd/config.toml] stopSend SIGTERM to the running server (Unix only)
rircd [--config /etc/rIRCd/config.toml] statusCheck if the server is running via PID file
rircd genpasswdInteractively hash a password for use in [[opers]]
rircd adduser <nick> [password]Create an account without connecting as a client; reads the password from stdin when omitted

--config is a global flag, so it goes before the subcommand.

The PID file is written to the same directory as config.toml (e.g. /etc/rIRCd/rircd.pid) and is removed on clean shutdown. rircd stop and rircd status use it to find the process.


User Accounts

User registration is handled via the IRC REGISTER command (draft/account-registration):

REGISTER * [email|*] <password>

The account name must be * or your current nick — the server advertises before-connect (so registering during connection negotiation, before CAP END, works) but not custom-account-name.

This stores a bcrypt hash of the password (for SASL PLAIN) and full SCRAM credentials (for SASL SCRAM-SHA-256) in MariaDB. Passwords must be at least 6 characters. On success the client is logged in immediately, exactly as if it had authenticated with SASL.

Email verification

If an [email] section is configured, registration takes a second step. REGISTER then requires a real address, and replies:

REGISTER VERIFICATION_REQUIRED <account> :A verification code has been sent to <email>

The account exists but cannot authenticate — SASL PLAIN, SCRAM-SHA-256 and EXTERNAL all refuse it — until the mailed code is confirmed:

VERIFY <account|*> <code>

On success the server replies VERIFY SUCCESS <account> and logs the client in. Codes are compared in constant time, are case-insensitive, and expire after code_expiry_secs (24h by default); an unverified account whose code has expired is deleted, freeing the name for registration again. Expired rows are also swept at startup.

If the mail cannot be sent, the registration is rolled back and the client receives FAIL REGISTER TEMPORARILY_UNAVAILABLE.

Authentication is supported via two SASL mechanisms:

SASL PLAIN:

CAP REQ :sasl
AUTHENTICATE PLAIN
AUTHENTICATE <base64-encoded NUL-separated authzid NUL authcid NUL password>

SASL SCRAM-SHA-256 (recommended):

CAP REQ :sasl
AUTHENTICATE SCRAM-SHA-256
AUTHENTICATE <base64-encoded client-first-message>
AUTHENTICATE <base64-encoded client-final-message>

SCRAM-SHA-256 uses PBKDF2 key derivation (4096 iterations) and provides mutual authentication — the server proves it knows your credentials without ever seeing your password in clear text over the protocol exchange.

Accounts are keyed by nick (lowercase). There is no separate admin interface for user management — use direct SQL queries on the users table if needed.


Reserved names

RESV [<seconds>] <pattern> :<reason> keeps a name for the network. A pattern beginning with # is about channels and anything else about nicks, so #help* never stops anybody being called helpdesk.

RESV #staff :for the people who run this place
RESV *serv :nobody here speaks for services

Somebody who asks for a reserved channel is told why (479), and somebody who asks for a reserved nick likewise (432). That is the difference between this and a spam filter: a filter is quiet on purpose, because telling a spammer what caught them hands them the way around it, while a reservation is a rule people are meant to be able to read.

Operators are not held to it — reserving the staff channel and then being unable to enter it would be a strange way to run a network — and a channel that already has people in it keeps them; the reservation stops anybody else coming in. Reservations are stored, cross links, and expire on their own when given a duration. UNRESV <pattern> gives the name back.

Shuns

A K-line closes the connection, which tells whoever was behind it to come back from another address. SHUN [<seconds>] <nick!user@host> :<reason> leaves them connected and lets nothing they say reach anybody:

  • They may listen, answer a PING, and leave. Everything else they type quietly does nothing — no error, no numeric, no hint. A shun that announced itself would just be a slower kill.
  • QUIT and PART still work, without the parting words: a quit reason is shouted into every channel they were in, so it is dropped with the rest.
  • They are never told, and neither is anybody else except the operators, under snomask b. STATS s is the operators' too, for the same reason: a list anybody could read would tell the shunned person they had been shunned.

Shuns live beside K-lines and D-lines: stored in the database, carried to every server when set, listed by STATS s, lifted by UNSHUN, and expiring on their own when given a duration. A mask made only of wildcards is refused, and so is one covering the operator setting it.

What a secret channel gives away

Nothing, and that takes saying, because it is the reply that leaks rather than the data. A stranger asking about a +s channel gets exactly what they would get for a channel that does not exist — the same numeric, the same text — from PRIVMSG, NOTICE, TAGMSG, KNOCK, TOPIC, NAMES, WHO, MODE, CHATHISTORY, LIST and CHANACCESS alike. One command answering differently is the whole of what +s was meant to hide, and the message is not delivered either.

A channel's own business stays inside it. The ban, exception and invite lists (MODE #channel b, e, I) are for the people in the channel: a stranger is told they are not on it, or for a secret channel that there is no such channel. WHO on a channel lists the members to the members; somebody carrying +i is listed to the people they share a channel with and to nobody else.

A new channel is created +nt. Without +n anybody may talk into a room without joining it, which is how one spammer reaches a hundred rooms without ever appearing in one; without +t any of the people in it may rewrite the topic. An operator who wants either off can take it off.

Who stands where in a channel

^ founder   +x   the person the channel belongs to
& admin     +a   somebody they have put above the operators
@ operator  +o
% half-op   +h
+ voice     +v

Status is given and taken by people who have some of their own, and nobody reaches above themselves: an operator may op an operator as they always could, but not appoint an admin over their own head, and an admin may not unseat the founder. A server operator stands outside the ladder.

A half-operator is the one exception to "an equal may act on an equal": they may not throw out another half-operator, or appoint one. Two of them could otherwise take a channel apart between them, which is the whole reason the rank is half of something.

^ is not a mode anybody sets, so it moves when the registration moves: CHANOWN takes it off the outgoing founder and gives it to the incoming one there and then, and CHANDROP — or the account expiring, or being dropped — takes it off altogether. A channel that belongs to nobody has nobody wearing it.

^ is not a mode anybody sets on the founder. It comes from the registration, so it is true again every time they walk in and cannot be taken from them by a MODE — CHANOWN is how a channel stops being theirs. & is an appointment for the visit, the way +h is: it is not written down.

The letters are x and a, not the q and a used by the servers that have these. +q here is the quiet list — persisted, taking timed bans, feeding +z — and was not worth breaking for a letter. Clients read the prefixes from PREFIX in ISUPPORT rather than assuming them, so what they show is what this server advertises: PREFIX=(xaohv)^&@%+.

Exemptions

ELINE [<seconds>] <mask> :<reason> says who none of that applies to.

Every blocklist eventually lists somebody who belongs here — a shared address, a VPN, an exit node — and without a way to say so the only answers are to stop believing the list for everybody or to stop asking it. Both give up more than the one address was worth. An exemption is how one address is vouched for while the rest of it stands.

ELINE *!*@10.0.0.0/8 :the office
ELINE 30d *!*@vpn.example :people we know come through here

It covers every kind of refusal this server makes on its own account: a D-line and the blocklist at the door, and a K-line or a shun once there is a nick to judge. It is judged twice — on the address alone before there is a nick, and on nick!user@host after — so being vouched for does not stop when somebody says who they are.

It is not a promise about behaviour. KILL still works, and so does a channel ban: an exemption is about the standing rules, not about an operator's hand.

Like the bans it lives in the database, goes to every server when set, expires on its own when given a duration, and is refused if the mask is nothing but wildcards — that one would vouch for the network and undo every ban at once. STATS e reads them back and UNELINE stops vouching. Unlike a ban, a mask covering the operator setting it is allowed: that check exists to stop somebody shutting themselves out by accident, and an exemption cannot.

A mask and a kind together are what name an entry, so a K-line, a shun and an exemption can each stand against the same mask and lifting one leaves the others.

Spam filters

SPAMFILTER is a pattern, what it is looked for in, and what happens when it is found — what other servers call the same thing. Operators with the ban privilege.

SPAMFILTER ADD <targets> <action> [<seconds>] :<pattern>
SPAMFILTER DEL <id|pattern>
SPAMFILTER LIST
SPAMFILTER TEST :<text>
PartMeaning
targetsLetters: p private messages, c channel messages, n nicks, t topics, q quit reasons, r real names — or * for all of them
actionwarn (let it through and tell the operators), block (refuse the line), kill (refuse it and close the connection), kline / dline (refuse it and ban the address for <seconds>, 0 for no end)
<pattern>A glob, matched as a ban mask is — say *phrase* to catch it anywhere in a line. Between slashes (/…/) it is a regular expression instead

A pattern made only of wildcards is refused, and so is one that matches the nick or real name of the operator adding it. Operators are never filtered: a filter that killed the person who could lift it is a server nobody can get back into. The sender is never told which pattern caught them — that would be handing them the way around it — so a blocked message is simply "not delivered", a blocked topic "not set", and a filtered quit reason becomes "Quit". The operators are told, under snomask f, and so is the log.

Filters are kept in the database, carried to every server when they are set, and bursted to a server that links afterwards, so the network agrees on them. Server bans and reservations are bursted the same way, capped at 512 of each: a server that has been running for years has a ban list that is not a greeting. SPAMFILTER TEST asks what a line would hit without anybody sending it, and LIST shows each filter's id, what it watches, and how many times it has matched since the server started. At most 64 filters, each pattern at most 512 characters: every line a client sends is held against all of them on the one dispatch loop, so the cost is a budget rather than a preference. Regular expressions are matched in time linear in the line, so a pattern cannot be made to hang the server.

Looking after your own account

There is no NickServ to message, so the account commands are commands. REGISTER and VERIFY make one, PASSWD changes its password, RESETPASS recovers it, GROUP reserves the other nicks you go by, and DROPACCOUNT ends it. Two more fill the gaps:

ACCOUNTINFO says what the server is holding about you — when the account was registered and last seen, the address on it, every nick grouped to it, the channels it founded, whether an operator has kept it out of the expiry sweep. An operator with the ban privilege can ask about somebody else's; nobody else can.

SETEMAIL <current password> <new address> moves the account to another address. The address is what a forgotten password goes to, so this is the one change that could quietly take an account away from the person who owns it, and it asks for two things: the password, which says it is you, and a code read at the new address, which says you can receive there.

SETEMAIL hunter2 me@newhost.example
-> NOTE SETEMAIL SENT me@newhost.example :A code is on its way…
SETEMAIL K7M2QJ4T
-> NOTE SETEMAIL CHANGED alice :alice is now at me@newhost.example

Until the code comes back the account keeps the address it had, so a borrowed session cannot point it somewhere else and wait for a reset. Reading the new address proves as much as reading the first one did, so an account that never verified is verified by this. One code at a time per account, and the address itself is held to the same mail_gap_secs as REGISTER and RESETPASS.

Grouped nicks

An account's own name is reserved for it. GROUP reserves the nick you are using as well — the work nick, the phone nick — so nobody else can sit on it, and so that being logged in is enough to use any of them. Up to five besides the account's own name; GROUP -<nick> gives one back, GROUP * lists them. You have to be using a nick to group it: reserving names you have never been seen under is squatting, and the server does not help with that. A grouped nick goes when the account goes, whether by DROPACCOUNT or by expiry, and cannot be registered as an account by anybody else while it is held.

Accounts, Channels and Moderation

rIRCd has no separate services package: what NickServ and ChanServ do on a traditional network is done by the server itself, against MariaDB.

Traditional services featurerIRCd
Nick registrationREGISTER / VERIFY (draft/account-registration), with optional email verification
IdentifySASL PLAIN, SCRAM-SHA-256 or EXTERNAL — no /msg NickServ
Nick protectionRegistered nicks are reserved for their account (nick_protection)
Nick recoveryGHOST <nick> closes a stale session of your own that is holding it, wherever on the network it is
Password changePASSWD <current> <new> — the current one is asked for even though you are logged in, and your other logins are closed
Password recoveryRESETPASS <account> mails a code; RESETPASS <account> <code> <new> uses it and closes every login to the account
Dropping an accountDROPACCOUNT <password> — removes the account and everything that named it; channels it founded are left with no founder
Channel access listCHANACCESS #channel — the founder, and everyone whose operator or voice status is remembered
Giving a channel upCHANDROP #channel — by the founder, or by an operator with the channels privilege; operators keep their standing
Channel founderThe account that creates a channel; always opped on join, never shut out of it by its own +b/+i/+k/+l, and not kickable or deoppable in it
Channel transferCHANOWN #channel <account> — by the founder, or by an operator, said out loud in the channel
Channel access listsMODE +o / +v on an account is remembered and restored on the next join. Somebody not logged in holds the status while they are there and no longer: a name proves nothing, so remembering one would hand the status to whoever took it next
Channel modes, topic, keyPersisted; kept when an owned channel empties, and restored on startup
Ban lists (AKICK-ish)+b, +e, +I and +q masks are persisted and restored
Network bansKLINE / UNKLINE, persisted and enforced on connect — on every server: a ban set on one crosses the links, closes what it matches there, and is written down there too. A mask made of wildcards is refused, typed or received. Hosts may be networks (*@203.0.113.0/24). DLINE / UNDLINE ban an address or network at the socket, before anything is spent on the connection
Server noticesUser mode +s with a mask of letters says which server notices an operator hears — a accounts, b bans, c connections, f floods, k kills and forced nick changes, l links, n nick changes, o OPER attempts, s server (REHASH, expiry). MODE <you> +s +cn adds, -s clears; a new operator starts with everything but connections and nick changes. HELP SNOMASK
VhostsSETHOST (oper), plus automatic cloaking via cloak_key
Server-side ignoreSILENCE +<mask> / -<mask> / SILENCE to list — a mask is somebody who does not exist to you: no message, notice or invitation of theirs arrives, and nothing tells them so. nick, nick!user@host and ~a:account are all masks; 32 per person, advertised as SILENCE=32

Still absent: per-channel access levels beyond operator and voice, AKICK, and memos.

Owning a channel

Whoever creates a channel while logged in is its founder, which is an account rather than a nick — whoever is logged in as it owns the channel, on any connection and under any name.

A founder is opped whenever they come back, cannot be kicked out of their own channel, cannot be deopped in it, and is not shut out by its own +b, +i, +k or +l. An operator turning on the person who appointed them is how a channel gets stolen, and none of those doors opens that way.

That leaves ownership needing a door of its own, which is CHANOWN:

CHANOWN #channel              what it says: who this channel belongs to
CHANOWN #channel <account>    hand it on

The founder may hand their own channel on — somebody leaves a project and the channel should go where the project went. A network operator may hand on any channel, because one whose founder has vanished, or whose founder is the problem, has no other way out. An operator doing it is announced in the channel as an operator and logged by name: an operator who wants a channel can already kick, mode and ban their way through it, so what keeps them honest is not being unable to act but being unable to act quietly.

The outgoing founder keeps operator status. Handing a channel on is not the same as being thrown out of it. The new owner must be an account that exists, and the change crosses the network as a CACCESS line, so every server agrees about whose channel it is.

CHANACCESS #channel lists who has the run of a channel — its founder, and everyone whose operator or voice status is remembered between visits. A status that is remembered is a status that can be forgotten about, and this is how a founder sees what they have given away. Like CHANOWN, it answers "no such channel" to somebody who cannot see a +s or +i channel.

CHANDROP #channel gives a channel up: the founder may give up their own, and an operator with the channels privilege may take the founder off any. The operators keep their standing, so the channel goes on being somebody's to run; it just stops being anybody's to own, and CHANOWN by an operator is how it gets an owner again. It is said in the channel, whoever did it.

Your account

There is no NickServ to SET PASSWORD at, so the server answers for it:

PASSWD <current> <new>                        change your password
RESETPASS <account>                           have a reset code mailed to you
RESETPASS <account> <code> <new password>     use it
DROPACCOUNT <password>                        remove your account

PASSWD asks for the current password even though you are logged in — a logged-in session is not proof of knowing it — and closes your other logins, since a changed password is meant to lock somebody out and a live session is the somebody. With multiclient, your other devices are the same login and stay.

RESETPASS <account> answers the same way whatever happened, because a command that said "no such account" would be a way of finding out which names are accounts. It sends at most one code per quarter hour to an account, the code is good for that long, and using it closes every login to the account: whoever is resetting the password cannot log in, so any session that exists is somebody else's. It needs [email]; without it there is nowhere to send a code.

DROPACCOUNT removes the account and everything that named it — its profile, its read markers, its push endpoints, its place on every operator and voice list — because a name that is free to register again must not come with a previous life attached. Channels it founded are left with no founder; their operators keep their standing and a network operator can give them a new one.

Every one of these is charged against the same failed-login budget as a bad SASL or OPER attempt, and so is REGISTER itself: each registration is a hash, a row, and with [email] a message to an address the client chose, which unbounded is an open mail relay for anybody who can connect. No address is mailed twice in a quarter hour whoever asks, by REGISTER or RESETPASS. A password check is a password check whatever command it arrives in.

Operators are told, by server notice, when somebody becomes an operator and when somebody fails to. It is how a stolen operator password gets noticed.

A channel made by somebody who is not logged in has no founder, and never gets one — there was no account to write down. channel_creation = "accounts" is the setting that closes that: it gates making a channel, never joining one, so every channel on the network is owned from its first moment. opers makes the channel list something decided rather than grown, which suits a private network and would be hostile on a general one.

Channel Persistence

Channels, topics, modes, operator lists, voice lists, and message history are all stored in MariaDB automatically.

A channel that belongs to somebody — it has a founder, or an operator whose status was granted to last — stays known when the last person walks out, with its modes, bans, topic and access lists intact, so it is still itself when somebody comes back. A channel nobody owns is forgotten when it empties, the way a name two people used for an afternoon should be: modes on their own do not keep one alive, because with nobody able to lift them a +k whose key is forgotten would seal the channel rather than save it.

  • Topic — persisted whenever a channel topic is set; 333 RPL_TOPICWHOTIME and 329 RPL_CREATIONTIME sent on JOIN.
  • Channel modes — mode flags (+imnstRcC), key (+k), user limit (+l), join throttle (+j) and flood limit (+f) are saved to the database on every MODE change, kept when an owned channel empties, and restored on startup.
  • Expiry — a registration is kept for as long as [expiry] says, which by default is forever; see that section.
  • Ban and exception lists — +b, +e, +I and +q masks are saved per channel and restored, so neither an empty room nor a restart makes an owned channel forget who was banned.
  • Operators / Voice — stored per channel by account; whoever logs in to one receives @/+ automatically when they join, under any nick. Status granted to somebody with no account applies while they are present and is not written down. Rows stored under a bare nick by an older version are dropped at startup, with a count in the log — they granted nothing that could be trusted, because nick reservation is what would have protected them and it fails open when the database is unreachable.
  • Direct messages — private conversations are stored per nick pair and replayed by CHATHISTORY <nick>; CHATHISTORY TARGETS lists only the requesting user's own conversations.
  • Message history — PRIVMSG, NOTICE, and channel events (JOIN, PART, QUIT, TOPIC, NICK) are appended (up to 1,000 entries per channel, oldest pruned). Clients with draft/chathistory can request history via CHATHISTORY LATEST/BEFORE/AFTER/AROUND/BETWEEN #channel <cursor> <limit> or list active conversations with CHATHISTORY TARGETS timestamp=<from> timestamp=<to> <limit>. Clients with draft/event-playback receive the full event timeline; otherwise only messages are returned.
  • Edit history — Edited messages retain a pointer to their original msgid. On CHATHISTORY replay, clients with draft/message-edit receive the +draft/edit tag so they can update their local buffer.
  • Read markers — MARKREAD timestamps are persisted per account in MariaDB and survive server restarts.
  • Metadata — METADATA key-value entries set on users and channels are persisted in MariaDB.

IRCv3 Support

Capability names follow the registry: features the specifications advertise with an ISUPPORT token (WHOX, UTF8ONLY, BOT, ACCOUNTEXTBAN) are not advertised as capabilities, and work for every client. Names rIRCd advertised before 1.4 are still accepted in CAP REQ so older clients keep working.

Capability / featureStatusNotes
capability-negotiationFullCAP LS/REQ/ACK/NAK/END, 302 multi-line
message-tagsFullParse & send tags; TAGMSG; msgid/server-time/account tags
Client-only tagsFullServer forwards +-prefixed tags on PRIVMSG/NOTICE/TAGMSG
server-timeFulltime tag on messages for capped clients
message-idsFullmsgid tag (with message-tags); unique id per message
batchFullNAMES and chathistory wrapped in BATCH
echo-messageFullPRIVMSG, NOTICE, TAGMSG echoed to sender when cap set
multi-prefixFullNAMES/WHO send all prefixes in rank order (^&@%+)
extended-joinFullJOIN #ch account :realname for clients with cap
account-tagFullaccount= tag on messages for capped clients
account-notifyFullACCOUNT on SASL login/quit to channel peers with cap
chghostFullSETHOST/SETUSER (oper-only); CHGHOST to channel peers with cap
setnameFullSETNAME command; broadcast to setname peers
away-notifyFullAWAY to channel peers with cap when user sets/unsets away
invite-notifyFullINVITE to channel members with cap when someone is invited
labeled-responseFullClient label tag echoed on all replies
standard-repliesFullFAIL for SETNAME, REDACT, UTF-8 errors
no-implicit-namesFullNo NAMES burst on JOIN when client has cap
userhost-in-namesFullNAMES (353) with full nick!user@host when client has cap
UTF8ONLYFullISUPPORT token (not a capability); non-UTF-8 rejected with FAIL … INVALID_UTF8
cap-notifyFullCAP NOTIFY with current cap list on REQ/ACK and END; dynamic CAP NEW/CAP DEL on REHASH
draft/extended-isupportFullISUPPORT command; 005 before registration
WHOXFullISUPPORT token (not a capability); WHO <target> %fields[,token] answered with 354 RPL_WHOSPCRPL for any client
bot-modeFullBOT=B ISUPPORT token (not a capability); umode +B, bot tag, RPL_WHOISBOT (335)
draft/oper-tagFulldraft/oper=<name> tag on messages from IRC operators, for clients with the cap
draft/message-redactionFullREDACT command; soft-delete in DB; CHATHISTORY replays REDACT events for client sync
draft/message-editFullPRIVMSG with +draft/edit=<msgid> tag; DB-backed ownership check; edit history replayed in CHATHISTORY
draft/reactFullTAGMSG with +draft/react=<emoji>; forwarded via client-only tag relay
draft/unreactFullTAGMSG with +draft/unreact=<emoji>; forwarded via client-only tag relay
typingFullTAGMSG with +typing=active/paused/done; forwarded via client-only tag relay
replyFullMessages with +reply=<msgid> tag forwarded as-is
ACCOUNTEXTBANFullISUPPORT token (not a capability); MODE +b ~a:account, JOIN 474 when banned by account
EXTBAN muteFullISUPPORT EXTBAN=~,OSajmnrt; MODE +b ~m:nick!*@* keeps someone from talking without keeping them out. Voice lifts it, and MODE +e ~m:mask excepts from it. MODE #chan +b with no mask reads the list without needing op, and RPL_BANLIST/RPL_EXCEPTLIST/RPL_INVITELIST name who set each entry and when
saslFullAUTHENTICATE PLAIN, SCRAM-SHA-256, and EXTERNAL (TLS client cert); 903/904; certfp auto-associated on PLAIN/SCRAM login
monitorFullMONITOR +/−/C/L/S; 730/731/732/733/734; on join/quit/nick
extended-monitorFullAWAY/ACCOUNT/CHGHOST/SETNAME forwarded for monitored nicks; nick!user@host masks (*/?) may be monitored as well as plain nicks
stsFullStrict Transport Security; advertised in CAP LS only when TLS is configured; duration=2592000
draft/channel-renameFullRENAME old new [reason]; op-only; fallback PART+JOIN for clients without cap
draft/chathistoryFullCHATHISTORY LATEST/BEFORE/AFTER/AROUND/BETWEEN/TARGETS for channels and direct conversations; BATCH chathistory; DB-backed; limit 200
draft/event-playbackFullJOIN/PART/QUIT/TOPIC/NICK events stored in DB and replayed in CHATHISTORY
draft/network-iconFullOptional ICON= ISUPPORT token; config network.icon
draft/read-markerFullMARKREAD target [timestamp]; per-account, persisted in MariaDB. A client with no account keeps its marker for the life of the connection only — a connection id does not come back tomorrow to read it
draft/metadata-2 / draft/metadata-3FullMETADATA GET/LIST/SET/CLEAR/SUB/UNSUB/SUBS/SYNC; key-value per user and channel. A person's keys are filed under their account, not their nick, so a profile follows the person whatever they are called and the next holder of a name never inherits an avatar or a display name that was never theirs. Somebody with no account has their keys filed under the nick, never written down, and dropped when the connection goes; logging in during registration files them under the account instead. Metadata set under a nick by an older version is moved onto its account at startup, and dropped if no account by that name exists. Both names of the same specification are advertised; replies to a -3 client come back in a metadata batch and subscription notices as RPL_KEYVALUE. before-connect lets a client set its own keys during registration; RPL_WHOISKEYVALUE (760) carries them in WHOIS. An invite-only or secret channel does not hand its metadata to non-members
STATUSMSGFullPRIVMSG/NOTICE to @#channel (ops+) or +#channel (voiced+); advertised in 005 STATUSMSG=@+
draft/account-registrationFullREGISTER * [email] password; logs the client in on success; optional email verification via VERIFY ([email] config)
draft/multilineFullBATCH draft/multiline; max-bytes=4096, max-lines=20; fallback for non-multiline clients
draft/pre-awayFullAWAY during registration; applied after NICK/USER complete
draft/channel-contextFull+draft/channel-context tag forwarded to channel members
draft/client-batchFullClient-originated BATCH types collected and relayed to recipients
CLIENTTAGDENYFullOptional 005 token; config server.client_tag_deny
WebIRCFullWEBIRC password gateway hostname ip; config [webirc]
WebSocketFullIRCv3 WebSocket transport; listen_ws/listen_wss config; subprotocol text.ircv3.net
draft/webpushFullWEBPUSH REGISTER/UNREGISTER; VAPID-signed, aes128gcm-encrypted pushes for DMs and highlights; VAPID= ISUPPORT token; [webpush] config
draft/filehostFullHTTPS file upload endpoint with HTTP Basic auth (same credentials as SASL PLAIN); reuses [tls] certs; FILEHOST= / draft/FILEHOST= ISUPPORT tokens; MIME-typed downloads; configurable max upload size

Roadmap

Features under consideration for future releases:

FeatureDescription
Offline direct messagesAccept a PRIVMSG to a registered user who is not connected, store it, and push it — today an offline nick still answers 401, and only channel mentions reach absent users
custom-account-nameLet an account be named something other than the current nick; needs channel op/voice lists to stop treating nicks and account names as interchangeable first

Standard IRC Commands

In addition to IRCv3 features, rIRCd implements the standard IRC command set:

CommandNumericsDescription
LIST321/322/323List channels. ELIST=CMNTU: >N/<N by user count, a glob name mask, !mask for the channels it does not match, C>N/C<N by how long ago the channel was created, T>N/T<N by how long ago the topic was set (both in minutes)
LUSERS251/252/254/255/265/266Server user/channel statistics
VERSION351Server version string
TIME391Server local time
INFO371/374Server info and uptime
LINKS364/365Linked servers (single-server: lists self)
STATS u242/219Server uptime
STATS o243/219IRC operator list
STATS k216/219Server bans in force, with time remaining
STATS m212/219How often each command has been used
WHOWAS314/312/369Historical nick info; the last 5 visits of each of the last 10,000 nicks in memory, 20 per nick in the database for 30 days
WHO mask352/315Supports glob masks (*, ?) against nick!user@host; respects +i invisible mode
HELP / HELPOP704/705/706Per-command help text
KNOCK710/711Request invite to an invite-only channel; notifies ops
KILL—Oper-only: forcibly disconnect a user; broadcasts QUIT to their channels
KLINE—Oper-only: KLINE [<seconds>] <mask> :<reason> — refuse connections matching a mask; existing ones are closed. The host may be a network in CIDR form (*@203.0.113.0/24, *@2001:db8::/32). Persisted in MariaDB
UNKLINE—Oper-only: remove a ban
DLINE—Oper-only: DLINE [<seconds>] <address|network> :<reason> — turn an address away the moment it connects, before the handshake, the blocklist lookup or a connection slot has been spent on it. CIDR for a network; nothing wider than a /8 (IPv4) or /16 (IPv6). Crosses links like KLINE; STATS d lists them
UNDLINE—Oper-only: lift a D-line
TESTMASK—Oper-only: TESTMASK <mask> — how many people a K-line on this mask would hit, here and on the rest of the network (724), before anybody sets it
SPAMFILTER—Oper-only (ban): patterns an operator would rather never see again; see Spam filters
SHUN—Oper-only (ban): SHUN [<seconds>] <mask> :<reason> — leave somebody connected and let nothing they say reach anybody. See Shuns
RESV—Oper-only (ban): RESV [<seconds>] <pattern> :<reason> — a name this network keeps for itself. #-patterns are channels, anything else nicks
UNRESV—Oper-only (ban): give a reserved name back
UNSHUN—Oper-only (ban): lift a shun
ELINE—Oper-only (ban): ELINE [<seconds>] <mask> :<reason> — say who the bans and the blocklist do not apply to. See Exemptions
UNELINE—Oper-only (ban): stop vouching for a mask
GROUP—Reserve the nick you are using for your account; GROUP -<nick> releases one, GROUP * lists them
SETEMAIL—SETEMAIL <current password> <new address> then SETEMAIL <code> — move your account to another address. See Looking after your own account
ACCOUNTINFO—ACCOUNTINFO [<account>] — what the server is holding about an account. Yours without asking; somebody else's needs the ban privilege. ACCINFO is the same command
NOEXPIRE—Oper-only (channels): keep an account or a channel out of [expiry]'s reach
MAP—The network as a tree with a user count per server (015/017)
STATS—STATS u uptime and STATS m command counts are for anybody; o (operator blocks), k (K-lines), d (D-lines), s (shuns), e (exemptions), y (connection classes — 218), l (what each connection has carried: send queue, messages and bytes each way, how long it has been open — 211) and t (what this server has been doing — 249) are for operators
TRACE—Oper-only: TRACE [<nick>] — the connections this server is holding (204/205) and the servers it is linked to (206), ending with 262. The class is how each one arrived: plain, tls or websocket
SAJOIN—Oper-only (channels): SAJOIN <nick> <#channel> — put somebody in a channel. The server invites them, so +b, +i, +k, +l and +j open; +O, +Z and +R still hold, because a forced join that broke a channel's promise would be the server lying on the operator's behalf. Somebody on another server is joined by that server at this one's request
SAPART—Oper-only (channels): SAPART <nick> <#channel> [:<reason>] — take somebody out of a channel; an ordinary PART, with the reason given
SAMODE—Oper-only (channels): SAMODE <#channel> <modes> [<args>] — set channel modes without holding ops there. Shown as the operator's own MODE; the operators are told it was done this way
DIE—Oper-only: shut the server down
ADMIN256/257/258/259Who runs this server ([server] admin_*)
GHOST—Close a stale session holding a nick your account owns, on this server or another one
WALLOPS—Oper-only: broadcast a message to all users with +w
MOTD375/372/376Send the message of the day
ISON303Check which nicks in a list are currently online
USERHOST302Return nick=+user@host info for up to 5 nicks

User Modes

ModeSet byDescription
+BUserBot mode — shown in WHOIS as a bot (RPL_WHOISBOT 335)
+iUserInvisible — hidden from WHO unless sharing a channel
+oServerIRC operator — set by successful OPER command
+rServerRegistered — set automatically on SASL login
+wUserReceives WALLOPS broadcasts from opers
+sOperServer notice mask — MODE <you> +s [+|-]<letters> chooses which server notices you hear (a accounts, b bans, c connections, f floods, k kills, l links, n nick changes, o OPER attempts, s server); -s clears it. HELP SNOMASK
+gCallerid — only people on your ACCEPT list may send you direct messages. A sender who is not is told once (716/717) and you are told who knocked (718), once a minute per knocker. ACCEPT <nick>, ACCEPT -<nick>, ACCEPT * to list

Channel Modes

ModeDescription
+oChannel operator
+vVoice (+)
+hHalf-op (%) — a moderator. May set the topic under +t, voice, ban, moderate, and throw out somebody below them, but may not hand out status at or above their own, and may not act on somebody standing level with them. Appointed by an operator: one who could appoint another could fill a channel with them
+bBan list — glob masks and the extended bans ~a: (account), ~r: (real name), ~j: (in another channel), ~S: (client certificate), ~O (operators), ~m: (mute rather than ban), ~n: (no nick change) and ~t: (lifts itself). The prefixes peel one at a time, so they stack: ~m:~r:*spam* mutes by real name, ~t:1h:~j:#raiders expires
+eBan exception list — exempt users bypass +b bans
+IInvite exception list — matching users bypass +i without explicit INVITE
+qQuiet list — silences matching users without kicking. Not the founder mode it is on some servers: that is +x here, because this list is persisted, takes timed bans and feeds +z, and was not worth breaking for a letter
+xFounder, shown as ^ — the person the channel belongs to. Given on join to whoever the registration names, so it cannot be taken by a MODE, and settable only by the founder or a server operator
+aAdmin, shown as & — somebody the founder has put above the operators. An appointment for the visit, like +h: it is not written down
+iInvite-only
+mModerated — only +v/+h/+o may speak
+nNo external messages
+sSecret channel — see What a secret channel gives away
+tTopic restricted to ops. Set on every new channel
+nNo messages from outside the channel. Set on every new channel
+kChannel key (password)
+lUser limit
+jJoin throttle, <joins>:<seconds> — no more than that many joins in that many seconds (480 past it). Whoever holds the channel, whoever it invited, and operators are let past, so a join flood slows the crowd without locking the owner out
MLOCKNot a mode but a lock on them: MLOCK #chan +nt-k says n and t stay on and k stays off, and a MODE from anybody but the founder (or a server operator) that would change one is refused (742). Kept with the channel, carried to every server. MLOCK #chan shows it, MLOCK #chan OFF clears it
+fFlood limit, <lines>:<seconds> — one line over it from one person and the server kicks them (Channel flood (limit is 5 lines in 10 seconds)); the line itself is not delivered. Ops, half-ops and operators are not the crowd it is for, and the founder cannot be kicked by it any more than by anyone else
+NNo nick changes while in the channel (447), unless you are an op or half-op there, or a server operator
+TNo NOTICEs to the channel from anybody who is not an op or half-op; dropped silently, as a refused NOTICE always is
+zOp-moderated: what somebody who may not speak (+m, +M, +q, a mute) says is delivered to the ops and half-ops as a message to @#channel instead of being refused — moderation the moderators can see
+OOperators only: nobody else may join (520). Only a server operator may set it
+LOverflow channel: when the channel is full (+l), somebody joining is told where they are being sent (470) and joins #overflow instead. One hop only
+b ~t:…A timed ban: +b ~t:30m:nick!*@* lifts itself after 30 minutes — s, m, h, d, or a bare number of minutes, up to a year. The server removes it with a MODE -b everybody sees, on every server. The same on +q, and around another extban (~t:1h:~a:account)
+b ~r:…A ban on the real name rather than the hostmask: +b ~r:*seedy_marketing*. A glob, because a real name is a sentence — and since a mode parameter cannot hold a space, _ stands for one on both sides. Somebody who gave no real name is matched only by a pattern that matches nothing in particular
+b ~j:#chanA ban on being somewhere else: +b ~j:#raiders keeps out whoever is in #raiders at the moment they try to come in
+b ~Z:…There is no such type, and a mask naming one is refused with 696 rather than kept. An extended ban whose type this server does not have falls through to being matched as a plain glob — a ban on somebody literally called ~Z:spammer, which is nobody — and it looks exactly like a ban that works. The same goes for a type given nothing to work with (~a:), ~O given something, ~j: given a name that is not a channel, and a mask wrapped in itself past any sense. Exceptions and invite exceptions are checked the same way: one that matches nobody is worse than a ban that does
+b ~S:…A ban on the TLS client certificate: ~S:* is everybody who brought one, ~S:3b8f* a particular one. Mostly useful as +e ~S:* — anybody who can prove who they are is excepted
+b ~n:…Keeps one person's name still: they may talk, but not change nick while in the channel (447). The one-person version of +N, lifted by +e ~n:mask. Not a ban on coming in
+b ~OMatches the server's operators. Written as a ban it is legal and does nothing useful; written as +e ~O it excepts them from what the room bans
+RRegistered users only — unregistered users cannot join or speak
+MOnly registered users may speak; anybody may join. Somebody given a voice or ops may speak regardless, as with +m. The anti-spam mode for a channel that wants to stay open to lurkers
+ZTLS only — a connection not over TLS cannot join, and the mode cannot be set while anybody in the channel is not on TLS (490). What is said in a +Z channel has never crossed a wire in the clear on any hop this server controls
+cStrip mIRC colour and formatting codes from messages
+CBlock CTCP messages (including /me actions)

What it says it does

ISUPPORT is a promise. A client that reads NICKLEN=32 will not offer a longer one; one that reads TOPICLEN=307 will not warn somebody their topic is about to be cut. Each of those numbers lives in exactly one place now — the token the server sends and the code that enforces it read the same constant — because they used to be written twice, which is how CHANLIMIT came to advertise fifty while the configuration said something else.

tests/smoke/test_isupport.py holds the server to it. Every expectation in that suite is read out of the server's own ISUPPORT rather than written down, so it does not check that the numbers are any particular value: it checks that the numbers the server gives out are the numbers it keeps. A nick one character over the advertised length is refused, a topic fifty over is cut to exactly it, a list takes the number it promises and refuses the next, and changing a configured limit changes both the token and the behaviour.

Performance

Measured rather than claimed. tests/smoke/throughput.py <receivers> <senders> puts every client in one channel, so each message a sender posts is delivered to every receiver, and reads the server's own CPU out of /proc around the burst alone — so the figures are steady-state delivery, not the cost of setting the connections up.

tests/smoke/run.sh --stop
SMOKE_BIN=$PWD/target/release/rircd tests/smoke/run.sh --serve-only
SMOKE_SERVER_PID=$(pgrep -x rircd) python3 tests/smoke/throughput.py 500 5

SMOKE_BIN matters: the suites run the debug build because they are about behaviour, and a debug build spends most of its time somewhere production never goes. On a quiet sixteen-core machine over loopback, 500 receivers in one channel and 5 senders:

Deliveries~126,000 a second
One message to all 500p50 9.7 ms, p99 19.2 ms
Server CPU per delivery~21 µs — about 9 µs its own work, about 12 µs asking the kernel

The second half of that is the interesting one. Every message is written to every recipient as it arrives rather than being held back to fill a buffer, so a delivery costs roughly one task wakeup and one write on the recipient's socket. That is the price of a chat server that answers immediately, and it is where the time goes: the server's own work — matching, tagging, copying — is the smaller half, and the fan-out loop builds one copy per set of negotiated capabilities rather than one per person so that it stays that way.

Testing

cargo test runs the unit and integration tests: message formatting, RFC 8291 encryption vectors, capability gating, verification-code rules, and fuzzers that throw arbitrary bytes at the parser and at the link protocol.

End-to-end, against a real server with a throwaway MariaDB and an SMTP sink behind it:

tests/smoke/run.sh                  # every suite, then tear down
tests/smoke/run.sh --keep           # leave it up for manual poking
tests/smoke/run-link.sh             # two servers, linked
tests/smoke/run-link.sh --tls       # the same, over an encrypted link

And against progval/irctest, which is the suite the other IRC servers are measured with:

tests/smoke/run.sh --serve-only     # it needs the database
tests/irctest/run.sh

Where that leaves things, on the same checkout with the same markers: rIRCd passes 544 of 557 with nothing failing, and four more under IRCTEST_RIRCD_CASEMAPPING=rfc1459 — which this server implements and the default run therefore skips. The six that never run are two for non-UTF-8 messages, refused on purpose, and four WHO tests irctest marks "not consistently implemented" and skips for everybody. For something to measure against, Ergo 2.19.1 on the same machine passes 527 and skips 26.

Two of the suites are about being attacked rather than being correct: test_hostile.py is a client that is not trying to be a client, and test_link_hostile.py is a peer that lies about which servers it speaks for. And soak.py runs for hours against a linked pair, watching for memory or open files that grow when they should not.

See tests/smoke/README.md and tests/irctest/README.md.


License

BSD 3-Clause. See LICENSE.

irc
rust
server