14.3 KiB · text History 6de4808

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

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.

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