OhMyExOhIrcd
A clusterable IRC daemon written in Elixir on top of Phoenix, Horde, and DeltaCrdt — with persistent channel registration, ChanServ-style services, IRC-over-TLS, and ACME cert provisioning baked in.
Status: working dev environment with 196 tests passing. Not yet battle-hardened at scale. PRs welcome.
What’s in the box
Distribution
- libcluster for node discovery (gossip locally, Kubernetes strategy in k8s).
- Horde for cluster-wide
Channel + user nick singletons and
service_set_access-grade services (ChanServ). - DeltaCrdt-backed per-channel message log keyed by HLC timestamps, so partitioned nodes can accept writes independently and replay deterministically on heal.
- Phoenix.PubSub for local fan-out on top of the CRDT.
IRC features
- RFC 2812-ish protocol:
NICK / USER / PASS / CAP / AUTHENTICATE (SASL PLAIN) / JOIN / PART / QUIT / PRIVMSG / NOTICE / PING / PONG / TOPIC / NAMES / WHO / WHOIS / LIST / LUSERS / MOTD / MODE / KICK / INVITE / AWAY / USERHOST / ISON. - Channel access prefixes
~ & @ % +(founder / admin / op / halfop / voice) with proper rank-based permission checks. - Channel modes:
+b/+e/+I(ban / except / invite-exception masks with glob matching),+i(invite-only),+m(moderated),+n(no external),+k <key>,+l <limit>. Mode views viaMODE #ch betc. ban-list-style numerics (367/348/346). - ChanServ service with
REGISTER / DROP / INFO / OP / DEOP / HOP / DEHOP / VOICE / DEVOICE / TRANSFER / HELP. Service nicks are configurable and reserved at the registry level. - Server-initiated PING keepalive with configurable interval + timeout (defaults: 120s / 60s).
- Hybrid netsplit semantics: classic
QUITfor presence on split, CRDT replay of PRIVMSG history on heal.
Auth & persistence
- Phoenix
phx.gen.authuser accounts. IRC clients authenticate via SASL PLAIN or classicPASS email:passwordagainst the same Phoenix credentials. - Optional
allow_anonymousflag for friction-free dev / open networks. - Persisted channel registrations (
channelstable) with founder auto-promotion on join. - Persisted channel-level modes (key, limit, flags, mask lists)
serialized as JSONB on the
channels.modescolumn — restored on Channel singleton restart. - Persisted per-user member access (
channel_membershipstable) — granting+oto an authenticated user survives restarts and reconnects.
Operations
- Live LiveView landing page showing version, channel count, user count, cluster size, and per-mode connection instructions.
- TLS via site_encrypt in
:standalonebuilds (in-process ACME against Let’s Encrypt or the bundled internal CA), OR via externally-mounted cert files (cert-manager, certbot, your reverse proxy) in:kubernetesbuilds. See:tls_modebelow. - GitLab CI builds both image variants in parallel.
- Example deployment manifests for both shapes under
deploy/.
Quick start (dev)
# Phoenix
mix setup # deps + ecto.create + ecto.migrate + assets
mix phx.server # web UI on http://localhost:4666, IRC on 6667
# Connect with any IRC client:
# /server localhost 6667
# In dev allow_anonymous is on, so any nickname works.
Visit http://localhost:4666/users/register to make a Phoenix account if you want to test SASL auth or register channels.
Inviting users (registration tokens)
When registration_mode is :invite_only (the default in prod),
/users/register is gated — a visitor needs a single-use invite token
to create an account. Tokens are minted out-of-band and shared as a URL.
Mint one with the mix invites.create task:
# Simplest — never expires, no note:
mix invites.create
# With a note and a 7-day expiry, against a specific public host:
mix invites.create --note "for alice" --expires-in-days 7 \
--base-url https://irc.skyjeep.club
It prints the token and the full signup link, e.g.:
Token: k3mfO0Yb9...
URL: https://irc.skyjeep.club/users/register?invite=k3mfO0Yb9...
Send that URL to the person you’re inviting — visiting it pre-fills the
gate and lets them register. The token is single-use (consumed when
the account is created) and optionally time-limited; a consumed or
expired token falls back to the locked gate. The --base-url defaults
to https://$PHX_HOST (or http://localhost:4666 if unset), so set it
explicitly when generating links for a different host.
List the outstanding (unconsumed) tokens with:
mix invites.list
In prod (a release, no Mix): the mix tasks aren’t available, so use
the OhMyExOhIrcd.Release helpers via eval instead — they start the
Repo on demand and print to your terminal:
bin/oh_my_ex_oh_ircd eval \
'OhMyExOhIrcd.Release.create_invite(note: "for alice", expires_in_days: 7)'
bin/oh_my_ex_oh_ircd eval 'OhMyExOhIrcd.Release.list_invites()'
(Use eval, not rpc — rpc runs on the live node, so its output would
go to the server’s console, not yours. The signup URL is built from the
Endpoint’s :url host, i.e. PHX_HOST.)
From IRC, as an operator: if you’d rather not touch a shell at all, an IRC operator (see below) can mint and list invites in-band. The server replies with NOTICEs carrying the token and signup URL:
/quote MKINVITE # never-expiring, no note
/quote MKINVITE 7 # expires in 7 days
/quote MKINVITE 7 :for alice # 7-day expiry, with a note
/quote LSINVITE # list active (unconsumed) invites
A note must follow a day count (the IRC grammar can’t otherwise tell a
bare note from a day count). Both commands require operator status and
reply 481 otherwise. (/quote sends a raw command; some clients use
/raw or accept /mkinvite directly.)
From the web UI: operator-flagged accounts get an Admin link in
the account menu, linking to /admin. From there an oper can create
invites (note + optional expiry), copy each single-use signup link,
revoke outstanding invites, see every registered channel network-wide
(the operator counterpart to the per-user “Registered channels you can
manage” page), and see everyone currently connected to the network —
each tagged registered / anonymous / service, with their account, host,
and node. The page is gated to opers — see
Creating an IRC operator for the flag.
Registration mode itself is set from the REGISTRATION_MODE env var in
runtime.exs (open for free signup, anything else → invite_only).
With :open, the gate is off and invite tokens are unnecessary.
Creating an IRC operator
There are two ways to become an IRC operator (network staff who can
KILL, WALLOPS, etc.). The account-flag path is the normal one; the
static config map is a DB-free break-glass fallback.
Account flag (recommended)
Flag an existing Phoenix account as an oper with the mix opers.grant
task, keyed by email:
mix opers.grant alice@example.com # set the is_oper bit
mix opers.list # show flagged accounts
mix opers.revoke alice@example.com # take it away
In prod (a release, no Mix), use the OhMyExOhIrcd.Release equivalents
via eval:
bin/oh_my_ex_oh_ircd eval 'OhMyExOhIrcd.Release.grant_oper("alice@example.com")'
bin/oh_my_ex_oh_ircd eval 'OhMyExOhIrcd.Release.list_opers()'
bin/oh_my_ex_oh_ircd eval 'OhMyExOhIrcd.Release.revoke_oper("alice@example.com")'
That user then connects, SASL-authenticates (so the server knows
which account they are), and sends a bare OPER with no arguments:
/oper
The server checks the is_oper bit on their authenticated account and,
if set, grants operator status. No password is exchanged over IRC — the
SASL login is the proof of identity.
Static O-line (break-glass)
The classic OPER <name> <password> flow works against a static
:opers config map with no DB round-trip — handy for bootstrap and
disaster recovery. Add an entry (key = oper login name):
# config/dev.exs, or env-specific / runtime config in prod
config :oh_my_ex_oh_ircd, :opers, %{
"admin" => %{
# iex> Bcrypt.hash_pwd_salt("super-secret-passphrase")
password_hash: "$2b$12$7ZFGtxWehSNu6j2qcqAcL.hlDG/trb1KaAq2v1lxLfzLRLMfnZrDW",
host_mask: "*@*" # IRC glob on the connecting client's user@host
}
}
host_mask is matched case-insensitively against the connection’s
user@host; "*@*" allows opers from anywhere, tighten it (e.g.
"*@10.0.0.*") to pin staff to known hosts. This map is config, not an
env var, so changes need a redeploy / restart. Then:
/oper admin super-secret-passphrase
Either path, on success you get 381 You are now an IRC operator plus a
MODE +o on your nick. Failure numerics: 491 (host doesn’t match
host_mask), 464 (wrong password, or account not flagged), 461
(missing parameters).
TLS / ACME modes
:tls_mode is a compile-time flag in config/config.exs (or set
via the TLS_MODE env var when building). Two values:
:standalone
: The Phoenix endpoint is wrapped by SiteEncrypt.Phoenix.Endpoint.
site_encrypt handles HTTP-01 challenges and obtains / renews
certs automatically. In dev it uses an in-process internal CA;
in prod, configure ACME_DIRECTORY_URL to Let’s Encrypt
staging or production. Cert files end up under
ACME_DB_FOLDER and are read by the IRC TLS listener.
:kubernetes
: The endpoint is plain Phoenix.Endpoint; site_encrypt is
compiled out entirely. The IRC TLS listener reads cert files
from explicit IRC_TLS_CERT_PATH + IRC_TLS_KEY_PATH — in a
typical k8s deployment those point at a Secret mounted from
cert-manager. Renewals propagate automatically because :ssl
re-reads keyfile/certfile on each handshake.
Both share the same OhMyExOhIrcd.IRC.TLSListener module; the only
diff is who provisions the cert.
Deployment
deploy/
├── kubernetes/ # :kubernetes image variant
│ ├── 00-namespace.yaml
│ ├── 10-rbac.yaml # ServiceAccount + Role for libcluster
│ ├── 20-postgres.yaml # baseline StatefulSet
│ ├── 30-secrets.example.yaml # DATABASE_URL, SECRET_KEY_BASE, …
│ ├── 40-certificate.yaml # cert-manager Certificate
│ ├── 50-services.yaml # headless / web / IRC LoadBalancer
│ ├── 60-deployment.yaml # 3 replicas, mounts cert Secret
│ └── 70-ingress.yaml
└── standalone/ # :standalone image variant
├── docker-compose.yml + .env.example
└── quadlet/ # podman + systemd units
├── ircd.network
├── ircd-*.volume
├── ircd*.container
├── ircd.env.example
└── README.md
See deploy/README.md for the apply order and operating notes.
Database migrations
Prod releases run pending migrations automatically on boot — a synchronous migrator runs right after the Repo starts and before the IRC listeners / web endpoint come up, so clients never hit a half-migrated schema. In a cluster every node does this, but Ecto’s migration lock serialises them, so only the first node actually applies each migration.
This is on by default in prod; set MIGRATE_ON_START=false to opt out
(e.g. when you run migrations from a separate Kubernetes Job or
initContainer). You can still migrate manually any time with
bin/oh_my_ex_oh_ircd eval 'OhMyExOhIrcd.Release.migrate()'. In dev/test
the auto-migrator is a no-op — use mix ecto.migrate as usual.
Architecture sketch
┌─────────────────┐
│ Horde Registry │ (cluster-wide;
│ │ {:channel,name} +
│ │ {:user,nick} keys)
└────────┬────────┘
│
┌───────────────┐ ┌──────┴──────┐ ┌──────────────┐
│ Channel pid │ │ User pid │ │ ChanServ pid │
│ (singleton) │ │ (per conn) │ │ (singleton) │
└───────┬───────┘ └──────┬──────┘ └──────────────┘
│ │
▼ │
┌───────────────┐ │
│ MessageLog │◀──────────┘ (PubSub fan-out
│ DeltaCrdt │ on channel topics)
│ (per node) │
└───────────────┘
- Channel owns membership, topic, modes, mask lists, in-memory
state. Mutations to non-prefix modes persist to
channels.modes; member access changes persist tochannel_memberships. - MessageLog is one DeltaCrdt per node holding every channel’s
PRIVMSG log keyed by
{channel, hlc}. Nodes mesh viaset_neighbours(driven byNodeListener). The same drain path fans out live messages and on-heal replay, so receivers don’t care whether something was just typed or just merged. - Connection is a
ThousandIslandhandler per TCP socket. It parses RFC-2812 frames, maintains per-conn state, subscribes to PubSub channel topics, and registers itself in the Horde registry under{:user, downcased_nick}. PINGs are server-initiated on inactivity.
Build variants
.gitlab-ci.yml runs two Kaniko jobs on push to master, publishing:
$REGISTRY/oh_my_ex_oh_ircd:standalone-<sha>(+:standalone-master,:master,:latest)$REGISTRY/oh_my_ex_oh_ircd:kubernetes-<sha>(+:kubernetes-master)
Both come from the same Dockerfile; the only difference is the
TLS_MODE build-arg, which the runtime config picks up via
Application.compile_env.
Tests
mix test
async: true integration tests use start_supervised! to spin up
per-test ThousandIsland listeners on ephemeral ports; async: false
DB tests share the sandbox in :shared mode. The TLS handshake test
uses SiteEncrypt.Phoenix.Test.clean_restart/1 to drive the internal
CA. Full suite is ~37s on a laptop.
Project layout
lib/oh_my_ex_oh_ircd/ # main app
├── accounts/ # phx.gen.auth
├── channels/ # ChannelRecord + ChannelMembership schemas
├── channels.ex # registration context
├── cluster/ # Horde + libcluster glue
├── irc/ # protocol, channel, connection, services
│ ├── auth.ex # SASL/PASS verification
│ ├── chan_serv.ex
│ ├── channel.ex # the singleton GenServer
│ ├── connection.ex # ThousandIsland handler
│ ├── hlc.ex # hybrid logical clock
│ ├── listener.ex # plain IRC listener (6667)
│ ├── message.ex # RFC parser/encoder
│ ├── message_log.ex # per-node DeltaCrdt
│ ├── services.ex # boot-time ChanServ starter
│ ├── stats.ex
│ └── tls_listener.ex # TLS IRC listener (6697)
└── themes.ex
lib/oh_my_ex_oh_ircd_web/ # Phoenix UI
└── live/home_live.ex # the LiveView landing
priv/repo/migrations/ # users + channels + channel_memberships
deploy/ # k8s + standalone manifests
Learn more about the underpinnings
- Phoenix Framework: https://www.phoenixframework.org/
- Horde: https://github.com/derekkraan/horde
- DeltaCrdt: https://github.com/derekkraan/delta_crdt_ex
- libcluster: https://github.com/bitwalker/libcluster
- site_encrypt: https://github.com/sasa1977/site_encrypt
- RFC 2812 (IRC): https://datatracker.ietf.org/doc/html/rfc2812
- IRCv3: https://ircv3.net/