CIRCADA Standalone Server

Overview

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:

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.

Architecture

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.

Runtime workflow

Startup

  1. circada-server reads JSON config from -c/--config or CIRCADA_SERVER_CONFIG.

  2. The config is passed to circada.server.new.

  3. server:run() creates the Redis store if one was not injected.

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

  5. ISUPPORT overrides are loaded from Redis.

  6. TLS identity is loaded from tls.cert/tls.key.

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

Client registration

A new client starts with TLS user mode +Z. Registration requires:

Supported CAPs include:

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.

Channel workflow

Joining a missing channel creates it with default modes:

+ntZ

The first user becomes the channel owner/operator. Channel state is stored in Redis:

Implemented join/send gates include:

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

Command handling

The server is table-dispatched by IRC command. Implemented handlers include:

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.

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:

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:

Orchestration notices include:

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

Configuration

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"
  }
}

Top-level fields

FieldTypeRequiredDefaultDescription
hoststringno"0.0.0.0"Listener bind address.
portintegerno6697TLS IRC listener port.
server_namestringyesIRC server name used in numerics.
network_namestringyesIRC network name advertised in welcome/ISUPPORT.
descriptionstringno"CIRCADA Server"Server description stored in metadata.
motdstringno"Welcome"Single-message MOTD text.
tls.certstringyesPEM certificate path.
tls.keystringyesPEM private key path.
tls.hostnamestringnoserver_nameTLS identity hostname override.
redis.hoststringno"127.0.0.1"Redis host.
redis.portintegerno6379Redis port.
redis.dbintegernoRedis defaultRedis DB index.
redis.prefixstringno"CIRCADA"Redis key prefix.
redis.timeoutnumberno5Redis connect/command timeout.
reuseportboolnofalse/nilPassed to lev.listen.
ping_intervalintegerno90Seconds between idle-client PING probes (disconnect on missed reply).
extra_capsarrayno[]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

Running the server

Start directly:

circada-server -c /etc/circada/server.json

or via environment:

CIRCADA_SERVER_CONFIG=/etc/circada/server.json circada-server

Minimal systemd user unit

[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

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.

Account management

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.

ISUPPORT overrides

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.

Server/network rename

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.

Redis key model

All keys are prefixed by redis.prefix.

Important key families:

Key familyPurpose
METAschema version, PID, start time, server/network metadata
CHANNELSset of canonical channel names
CHAN:<channel>channel display name, owner, creation time, modes, topic
CHAN:<channel>:memberscanonical member nicks
CHAN:<channel>:ops / halfops / admins / voicedchannel status sets
CHAN:<channel>:bans / banex / invex / invitedchannel mask lists
CHAN:<channel>:encencrypted-channel metadata (enabled, epoch, creator_account, pending_init)
CHAN:<channel>:enc:wraps:<epoch>account→wrapped key map for retained epochs
CHAN:<channel>:enc:pendingqueued wrap requests awaiting eligible wrappers
CHAN:<channel>:enc:rotation:<epoch>staged rotation wraps before commit
CHAN:<channel>:enc:auditappend-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.

Development and verification

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.

Security notes