17.6 KiB · text History 1a276ca

OhMyExOhIrcd

CI status credo status license install oh-my-ex-oh-ircd

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 names must carry a channel-type prefix (# or &) — no prefix-less names — be 2–50 chars, and contain no spaces, commas, or :. A JOIN of anything else is refused with 403.
  • 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>, +f (flood-exempt — members of the channel bypass PRIVMSG/NOTICE flood control; founder-only to set/clear since it disables a built-in protection), and +A (auto-join — every connecting user is force-joined to the channel after registration; IRC-operator-only to set/clear, and only effective on a registered channel since the flag is read back from persistence at connect time). Mode views via MODE #ch b etc. 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 QUIT for presence on split, CRDT replay of PRIVMSG history on heal.

Auth & persistence

  • Phoenix phx.gen.auth user accounts. IRC clients authenticate via SASL PLAIN or classic PASS email:password against the same Phoenix credentials.
  • Optional allow_anonymous flag for friction-free dev / open networks.
  • Persisted channel registrations (channels table) with founder auto-promotion on join.
  • Persisted channel-level modes (key, limit, flags, mask lists) serialized as JSONB on the channels.modes column — restored on Channel singleton restart.
  • Persisted per-user member access (channel_memberships table) — granting +o to 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 :standalone builds (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 :kubernetes builds. See :tls_mode below.
  • 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) plus any unregistered channels currently live in the cluster, 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.

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 to channel_memberships.
  • MessageLog is one DeltaCrdt per node holding every channel’s PRIVMSG log keyed by {channel, hlc}. Nodes mesh via set_neighbours (driven by NodeListener). 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 ThousandIsland handler 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