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.
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.
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/