This document describes the standalone Redis-backed IRC server shipped
by the CIRCADA lilpack. The bouncer and shell UI are documented in
README.md.
The standalone server is exposed as:
circada.server — Lua module implementing the IRC server.
circada.server.store — Redis-backed persistence module.
circada-server — daemon entry point.
circadactl-server — direct Redis administration CLI.
It is intentionally separate from the circada bouncer. The bouncer
connects to existing IRC networks on behalf of one user; the standalone
server is an IRC network endpoint that clients connect to directly.
IRC clients
│ TLS IRC
▼
┌──────────────────────────────────────────────┐
│ circada-server │
│ ┌──────────────────────────────────────────┐ │
│ │ circada.server │ │
│ │ - TLS accept loop │ │
│ │ - IRC parser/formatter │ │
│ │ - registration, CAP, SASL │ │
│ │ - command handlers │ │
│ │ - in-process live client/channel cache │ │
│ └─────────────────────┬────────────────────┘ │
└───────────────────────┼──────────────────────┘
│ Redis commands
▼
┌──────────────────────────────────────────────┐
│ Redis │
│ - server metadata │
│ - account records and password hashes │
│ - reserved nicks │
│ - channels, modes, topics │
│ - membership/status/masks │
│ - presence and counters │
│ - ISUPPORT overrides │
└──────────────────────────────────────────────┘
The server uses Lilush's async runtime (lev) and TLS stack (litls).
Clients are accepted in clear TCP only long enough to perform the
server-side starttls upgrade; regular IRC traffic is TLS-only.
The process keeps live sockets and transient client state in memory.
Authoritative shared state lives in Redis through circada.server.store.
This is what allows the admin CLI to mutate accounts, reserved nicks,
ISUPPORT overrides, and server metadata without linking to server
process internals.
circada-server reads JSON config from -c/--config or
CIRCADA_SERVER_CONFIG.
The config is passed to circada.server.new.
server:run() creates the Redis store if one was not injected.
The store bootstrap() step:
initializes server metadata on first run;
records the current PID and start time;
removes stale presence records for dead PIDs;
prunes missing channels from the channel index;
rebuilds the invisible-user counter.
ISUPPORT overrides are loaded from Redis.
TLS identity is loaded from tls.cert/tls.key.
The server binds host:port and starts accepting clients.
SIGTERM and SIGINT are handled by the CLI wrapper and call
server:stop() for graceful shutdown.
A new client starts with TLS user mode +Z. Registration requires:
NICK
USER
completion of CAP negotiation, if started
if sasl was negotiated: SASL success or SASL abort
Supported CAPs include:
sasl=PLAIN
server-time
message-tags
account-tag
account-notify
away-notify
echo-message
extended-join
SASL supports PLAIN only. Successful SASL sets the account, user mode
+r, emits the standard logged-in/SASL success numerics, and allows
registration to complete. Failed SASL blocks registration until the
client either succeeds or aborts with AUTHENTICATE * followed by
CAP END.
Nicknames reserved to an account cannot be used anonymously or by a different account.
Joining a missing channel creates it with default modes:
+ntZ
The first user becomes the channel owner/operator. Channel state is stored in Redis:
channel metadata and modes;
topic and topic setter/time;
member set;
status sets: ops, halfops, admins, voiced;
mask lists: bans, ban exceptions, invite exceptions, one-shot invites.
Implemented join/send gates include:
+Z TLS-only channels;
+R registered-account-only join/send;
+E encrypted-channel orchestration mode (requires +R and +Z);
+k channel key;
+l member limit;
+i invite-only with invite/invex masks;
+b bans with ban exceptions;
+n no external messages;
+m moderated channels.
+E is set with MODE #chan +E by channel operators. Enabling +E also
sets +R and +Z on the channel. MODE -E is rejected.
When the last user leaves a non-registered channel, the channel is
deleted. Channels with mode +r are preserved.
The server is table-dispatched by IRC command. Implemented handlers include:
PING
PONG
Additionally, the server sends periodic PING probes to idle clients.
If a client does not answer before the next probe window, it is
disconnected with QUIT :Ping timeout.
CAP
NICK
USER
AUTHENTICATE
JOIN
PART
MODE
TOPIC
KICK
WHO
WHOIS
LIST
AWAY
PRIVMSG
NOTICE
TAGMSG
QUIT
IDENTITY
CKEY
WHO respects secret channels and invisible users. Invisible users are
visible to themselves, opers, and clients that share a channel with
them.
LIST returns channel information for all visible channels. Secret (+s)
and private (+p) channels are hidden from non-members; oper clients can
see all channels. An optional comma-separated channel list can be provided
to filter results.
AWAY sets or clears the user's away status. Setting away notifies
shared-channel peers who negotiated away-notify. Sending a PRIVMSG to
an away user returns 301 (RPL_AWAY) to the sender.
account-notify broadcasts ACCOUNT <nick> or ACCOUNT * to
shared-channel peers who negotiated the cap when the user logs in or out
via SASL.
Outbound tags are filtered per recipient capability. For example,
account-tag is only delivered to clients that negotiated
account-tag.
IDENTITY and CKEY are CIRCADA-specific encrypted-channel commands.
They use tagged NOTICE responses (@+circada/enc=1) for status,
errors, and orchestration events.
IDENTITY subcommands:
IDENTITY REGISTER <ed25519_pk_b64> <sig_b64>
IDENTITY SHOW [account|nick]
IDENTITY FINGERPRINT <account>
Registration is tied to the authenticated SASL account. REGISTER
verifies that the provided signature signs the canonical account string
with the submitted Ed25519 public key. On success the server derives and
stores the account X25519 public key and fingerprint.
CKEY subcommands:
CKEY FETCH <channel> [epoch]
CKEY INIT <channel> <epoch> <self_wrap_b64>
CKEY WRAP <channel> <target_account> <wrap_b64>
CKEY ROTATE_BEGIN <channel> <new_epoch>
CKEY ROTATE_WRAP <channel> <new_epoch> <account> <wrap_b64>
CKEY ROTATE_COMMIT <channel> <new_epoch> <sig_b64>
CKEY ROTATE_ABORT <channel> <new_epoch>
Orchestration notices include:
CKEY INIT_REQUEST <channel> <epoch> <creator_identity_blob>
CKEY WRAP_REQUEST <channel> <joiner_account> <joiner_identity_blob>
CKEY WRAP_DELIVERY <channel> <epoch> <wrap_b64>
CKEY ROTATE_REQUEST <channel> <new_epoch>
CKEY ROTATE_MEMBER <channel> <new_epoch> <account> <identity_blob>
Error/status tokens are carried via CKEY ERROR <code> <target> :text.
Examples: NOIDENTITY, BADSIG, IDENTITY_EXISTS, PENDING,
NOTENCRYPTED, ENCDATA.
ROTATE_COMMIT signatures are mandatory. The server verifies the
submitted Ed25519 signature against the rotator's registered identity
and stores the submitted base64 signature in the channel audit log.
The standalone server never decrypts channel payloads. Encrypted channel
messages are ordinary PRIVMSGs with opaque payload and +circada/*
tags, e.g.:
@+circada/enc=1;+circada/epoch=2;+circada/nonce=...;+circada/seq=42 PRIVMSG #chan :<ciphertext_b64>
Server join/send policy still applies (+n, +m, +R, +Z, bans,
invites, etc.).
Example /etc/circada/server.json:
{
"host": "0.0.0.0",
"port": 6697,
"server_name": "irc.example.com",
"network_name": "ExampleNet",
"description": "ExampleNet CIRCADA server",
"motd": "Welcome to ExampleNet",
"tls": {
"cert": "/etc/circada/server.crt",
"key": "/etc/circada/server.key"
},
"redis": {
"host": "127.0.0.1",
"port": 6379,
"db": 11,
"prefix": "CIRCADA"
}
}
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
host | string | no | "0.0.0.0" | Listener bind address. |
port | integer | no | 6697 | TLS IRC listener port. |
server_name | string | yes | — | IRC server name used in numerics. |
network_name | string | yes | — | IRC network name advertised in welcome/ISUPPORT. |
description | string | no | "CIRCADA Server" | Server description stored in metadata. |
motd | string | no | "Welcome" | Single-message MOTD text. |
tls.cert | string | yes | — | PEM certificate path. |
tls.key | string | yes | — | PEM private key path. |
tls.hostname | string | no | server_name | TLS identity hostname override. |
redis.host | string | no | "127.0.0.1" | Redis host. |
redis.port | integer | no | 6379 | Redis port. |
redis.db | integer | no | Redis default | Redis DB index. |
redis.prefix | string | no | "CIRCADA" | Redis key prefix. |
redis.timeout | number | no | 5 | Redis connect/command timeout. |
reuseport | bool | no | false/nil | Passed to lev.listen. |
ping_interval | integer | no | 90 | Seconds between idle-client PING probes (disconnect on missed reply). |
extra_caps | array | no | [] | Extra CAP tokens to advertise. |
The admin CLI also accepts the same JSON config file. If no config file is supplied, Redis can be selected with flags:
circadactl-server \
--redis-host 127.0.0.1 \
--redis-port 6379 \
--redis-db 11 \
--prefix CIRCADA \
account list
Start directly:
circada-server -c /etc/circada/server.json
or via environment:
CIRCADA_SERVER_CONFIG=/etc/circada/server.json circada-server
[Unit]
Description=CIRCADA standalone IRC server
After=network-online.target redis.service
Wants=network-online.target
[Service]
Type=simple
ExecStart=%h/.local/bin/circada-server -c %h/.config/circada/server.json
Restart=on-failure
RestartSec=5
TimeoutStopSec=10
[Install]
WantedBy=default.target
Adjust paths and service ordering for your Redis deployment.
Administration is performed by direct Redis writes through
circadactl-server. The server sees account/nick changes immediately
where handlers query Redis dynamically. ISUPPORT overrides and metadata
are loaded at server startup, so restart the server after changing them
if you need already-running clients to see the new values.
Create an account with an interactive password prompt:
circadactl-server -c /etc/circada/server.json account add alice
or pass the password explicitly:
circadactl-server -c /etc/circada/server.json account add alice 'secret-password'
List accounts:
circadactl-server -c /etc/circada/server.json account list
Delete an account and its reserved nick mappings:
circadactl-server -c /etc/circada/server.json account del alice
Reserve and release nicks:
circadactl-server -c /etc/circada/server.json account reserve-nick alice Alice
circadactl-server -c /etc/circada/server.json account release-nick alice Alice
A reserved nick can only be used by clients logged into the owning account.
Set, delete, and list ISUPPORT values:
circadactl-server -c /etc/circada/server.json isupport set NETWORK ExampleNet
circadactl-server -c /etc/circada/server.json isupport del NETWORK
circadactl-server -c /etc/circada/server.json isupport list
Overrides are merged into the server's generated ISUPPORT map at startup. Restart the server after changing them.
circadactl-server -c /etc/circada/server.json rename server-name irc.example.com
circadactl-server -c /etc/circada/server.json rename network-name ExampleNet
These commands update Redis metadata. On first bootstrap, config values seed metadata. On later bootstraps, existing metadata is preserved so admin renames survive restarts.
All keys are prefixed by redis.prefix.
Important key families:
| Key family | Purpose |
|---|---|
META | schema version, PID, start time, server/network metadata |
CHANNELS | set of canonical channel names |
CHAN:<channel> | channel display name, owner, creation time, modes, topic |
CHAN:<channel>:members | canonical member nicks |
CHAN:<channel>:ops / halfops / admins / voiced | channel status sets |
CHAN:<channel>:bans / banex / invex / invited | channel mask lists |
CHAN:<channel>:enc | encrypted-channel metadata (enabled, epoch, creator_account, pending_init) |
CHAN:<channel>:enc:wraps:<epoch> | account→wrapped key map for retained epochs |
CHAN:<channel>:enc:pending | queued wrap requests awaiting eligible wrappers |
CHAN:<channel>:enc:rotation:<epoch> | staged rotation wraps before commit |
CHAN:<channel>:enc:audit | append-only audit trail for encryption lifecycle events |
IDENTITY:<account> | account encryption identity (ed25519_pk_b64, x25519_pk_b64, fingerprint) |
Encrypted-channel key material is stored only as wrapped blobs
(CHAN:<channel>:enc:wraps:*/rotation staging); plaintext channel keys
never leave clients.
Account passwords are salted and stored as HMAC-SHA256 material using
litls.core primitives. SASL verification uses constant-time compare
for stored hashes.
Relevant tests:
lilush circada/tests/server/test_enc_core.lua
lilush circada/tests/server/test_enc_store.lua
lilush circada/tests/server/test_enc_client.lua
lilush circada/tests/protocol/test_replies.lua
lilush circada/tests/server/test_handlers.lua
lilush circada/tests/server/test_store.lua
lilush circada/tests/server/test_admin_cli.lua
Current coverage includes protocol reply builders, mocked handler behavior, Redis store operations, and the admin CLI against a temporary Redis fixture.
The standalone server is TLS-only for IRC traffic.
Redis is trusted infrastructure. Do not expose Redis to untrusted networks.
Passing account passwords on the CLI can leak through shell history and process lists. Prefer the interactive prompt for real accounts.
Reserved nicks are enforced at NICK time, but account policy is still intentionally minimal: there are no services, email flows, recovery flows, or operator ACLs yet.