clawbot 22c65f9100
check / check (push) Waiting to run
Rewrite README.md sentence by sentence (closes #190)
README.md is rewritten from scratch against the prose rules on issue 190
and checked statement by statement against the code on next. It leads
with what the program is and how to use it, documents the HTTP API, the
IRC listener, the web client and neoirc-cli, puts every setting in a
reference section, and ends with development, testing and a TODO
section that points at the tracker. Every reply number is in backticks,
every error list is bulleted, and a claim that holds for only one of
the HTTP API and the IRC listener says which. Where the old text
disagreed with the code, the PR body lists the disagreement; the code
decides.

Model: fable-5-1
2026-10-27 15:57:55 +00:00

neoirc

neoirc is an MIT-licensed chat server written in Go by @sneak. It carries IRC commands and replies as JSON over HTTP, and keeps each user's session on the server.

What neoirc is

neoirc is IRC with a different transport. The commands are IRC's: PRIVMSG, JOIN, PART, NICK, TOPIC, MODE, KICK. The replies are IRC's numeric replies, such as 353 and 433. Channels start with #. The first user to join a channel creates it, and a channel disappears when its last member leaves.

Four things differ from IRC:

  • A client talks to the server over HTTP. It sends commands with one POST request and reads its messages with one GET request.
  • The server holds the session. A session keeps its nick, its channels and its queued messages while no client is connected.
  • One session can have several clients at once, such as a phone and a laptop. Each client has its own message queue.
  • A message body is a JSON array of lines, not one line of text.

The server also has a plain IRC listener, on port 6667 by default. Standard IRC clients connect to it and share channels with HTTP clients.

The repository ships three programs:

  • neoircd, the server.
  • A web client, which the server serves at /.
  • neoirc-cli, a terminal client.

Why it exists

IRC ties a session to one TCP connection. When a phone drops the connection, the user loses the nick, the channels and every message sent in between. neoirc keeps the session on the server, so a client can disconnect and come back without losing anything. Everything else stays IRC: the same commands, the same replies, the same channel modes.

Getting started

Clone the repository, build the Docker image and run it:

make docker
docker run -d --name neoirc \
  -p 8080:8080 -p 6667:6667 \
  -v neoirc-data:/var/lib/neoirc \
  -e NEOIRC_HASHCASH_BITS=0 \
  neoirc

The server listens for HTTP on port 8080 and for IRC on port 6667. It keeps its data in /var/lib/neoirc/state.db. NEOIRC_HASHCASH_BITS=0 turns off the proof of work that session creation otherwise needs (see Hashcash).

Open http://localhost:8080/ for the web client, or talk to the API with curl:

# Create a session. -c saves the auth cookie, -b sends it.
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \
  -H 'Content-Type: application/json' \
  -d '{"nick":"alice"}'

# Join a channel.
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
  -H 'Content-Type: application/json' \
  -d '{"command":"JOIN","to":"#general"}'

# Say something.
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
  -H 'Content-Type: application/json' \
  -d '{"command":"PRIVMSG","to":"#general","body":["hello"]}'

# Read your queue. The request waits up to 15 seconds for messages.
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=15"

Or connect a standard IRC client to localhost port 6667.

In production, run the server behind a reverse proxy that terminates TLS (see Running the server). The auth cookie is marked Secure, so browsers send it only over HTTPS, or to localhost.

How it works

This section defines the terms the rest of this document uses.

A session is one user on the server. It has a nick, a username, a hostname, the channels it has joined and, optionally, a password. The server creates a session when a client sends POST /api/v1/session, or when an IRC client registers on the IRC listener.

A client is one connection to a session: a browser, a curl cookie jar, an IRC connection. Each client has its own queue of messages waiting to be read. An HTTP client also has its own auth cookie. A session starts with one client. POST /api/v1/login adds another client to a session that has a password.

A message is one JSON object (see The message envelope). The server stores each message once. For every client that should receive it, the server adds one entry to that client's queue. An HTTP client reads its queue with GET /api/v1/messages, which can wait for new messages; the listener does this for an IRC connection. Queue entries have increasing numbers. The client passes the last number it has seen as after, so it reads each entry once.

Three kinds of message reach a queue:

  • Chat messages: PRIVMSG and NOTICE. The server echoes a session's own to every client of the session, except an IRC connection that sent one.
  • Events: JOIN, PART, NICK, QUIT, TOPIC, MODE, KICK, INVITE and WALLOPS, telling the client what other users did. When a client's own JOIN, PART, NICK, TOPIC, MODE or KICK causes an event, every client of its session receives it too.
  • Numeric replies: the server's answers to commands, such as RPL_TOPIC or ERR_NOSUCHNICK. A numeric reply goes only to the client that sent the command.

A channel is a named room. Its name starts with #. The first user to join creates it and becomes its channel operator. A channel operator can set the channel's modes, change its topic, kick members and invite users. The channel is deleted when its last member leaves.

A server operator is a user who has sent OPER with the name and password from the server's configuration. A server operator can use KILL and WALLOPS. Over the HTTP API, WHOIS also shows a server operator the IP address of other users.

Nicks and channel names compare by exact case. Alice and alice are two nicks. #Upper and #upper are two channels. The HTTP API's 005 reply advertises CASEMAPPING=ascii; IRC has no value for names that compare by exact case.

Messages are never edited or deleted by clients. There is no endpoint for either.

Using the HTTP API

Conventions

  • Request and response bodies are JSON.
  • Endpoints marked "needs auth" need the neoirc_auth cookie. POST /api/v1/session and POST /api/v1/login set it. A request without a valid cookie gets 401 with {"error": "not registered", "numeric": 451}.
  • An endpoint in this section answers an error at the HTTP level with a 4xx or 5xx status and {"error": "<text>"}.
  • An error in a command is not an HTTP error. The request gets 200 with {"status": "error"}, and the reason arrives as a numeric reply in the client's queue.
  • Request bodies for POST /api/v1/session, POST /api/v1/login and POST /api/v1/messages are limited to MAX_MESSAGE_SIZE bytes (default 4096). A larger body gets 400 invalid request body.
  • The server allows cross-origin requests from any origin, with credentials. The cookie is SameSite=Strict, so a browser sends it only from the same site.

POST /api/v1/session and POST /api/v1/login answer with a Set-Cookie header:

Set-Cookie: neoirc_auth=<64 hex characters>; Path=/; HttpOnly; Secure; SameSite=Strict

The value is 32 random bytes in hex. The server stores only its SHA-256 hash and compares that on every request. The cookie is the only credential; the JSON body of the response does not repeat it. A browser sends the cookie by itself. Any other client must save it and send it back, as curl -c and curl -b do.

The cookie is Secure. Chrome, Firefox, curl and neoirc-cli send it to a server on localhost over plain HTTP too; Python's requests does not. For every other host the server needs HTTPS, which a reverse proxy provides.

QUIT, POST /api/v1/logout, KILL and the idle cleanup invalidate the cookie. After that, every request with it gets 401.

Endpoints

POST /api/v1/session: create a session

No auth. Request body:

{
    "nick": "alice",
    "username": "alice",
    "pow_token": "1:20:260310:neoirc::3a2f1"
}
  • nick (required): the nick. A nick is 1 to 32 characters: a letter or _, then letters, digits and _-[]\^{}|`. It must not be in use.
  • username (optional): the user part of the hostmask (see Hostmasks). 1 to 32 characters out of letters, digits and _-[]\^{}|`. Default: the nick.
  • pow_token: a hashcash stamp (see Hashcash). Required unless the server runs with NEOIRC_HASHCASH_BITS=0.

Response 201, with the cookie:

{ "id": 1, "nick": "alice" }

id is the session's number. The new client's queue already holds the welcome replies 001 to 005 and the LUSERS replies (see Numeric replies). It also holds the message of the day when MOTD is set.

Errors:

  • 400 invalid request body: not JSON, or too large.
  • 400 invalid nick format.
  • 400 invalid username format.
  • 402 hashcash proof-of-work required: no pow_token, and the server requires one.
  • 402 invalid hashcash stamp: <reason>.
  • 409 nick already taken.

The server checks the stamp before the nick, and spends it only when the session is created. A request refused with 400 or 409 leaves the stamp unspent.

POST /api/v1/login: add a client to a session

No auth. The session must have a password, set with the PASS command. Request body:

{ "nick": "alice", "password": "mypassword" }

Response 200, with a new cookie for the new client:

{ "id": 1, "nick": "alice" }

The new client's queue holds the welcome replies and the LUSERS replies, then the message of the day when MOTD is set. For each channel of the session it then holds:

  • a JOIN event from the session's own nick with an empty body;
  • the topic replies;
  • the member list.

A client can rebuild its channel windows from them.

Errors:

  • 429 too many login attempts, try again later, with Retry-After: 1. See Login rate limit.
  • 400 invalid request body.
  • 400 nick and password required.
  • 401 invalid credentials: wrong password, unknown nick, or a session without a password.

GET /api/v1/state: the session's state

Needs auth. Response 200:

{
    "id": 1,
    "nick": "alice",
    "channels": [
        { "id": 1, "name": "#general", "topic": "Welcome!" },
        { "id": 2, "name": "#dev", "topic": "" }
    ]
}

channels lists the channels the session has joined, sorted by name. With ?initChannelState=1, the server also fills the calling client's queue as POST /api/v1/login does. The web client uses this when it loads, to restore its channel tabs.

GET /api/v1/users/me is the same endpoint under another path.

GET /api/v1/messages: read the queue

Needs auth. Query parameters:

  • after (default 0): return only queue entries with a number greater than this. Pass the last_id of the previous response.
  • timeout (default 0): how many seconds to wait for a message when the queue has none. 0 answers at once. The server caps it at 30.

Response 200:

{
    "messages": [
        {
            "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5",
            "command": "PRIVMSG",
            "from": "bob",
            "to": "#general",
            "body": ["hello world"],
            "ts": "2026-02-10T20:00:01.234567891Z",
            "meta": {}
        }
    ],
    "last_id": 42
}

messages holds at most 100 messages, oldest first. last_id is the number of the last entry returned, or the after value when there was none.

When the queue has nothing after after and timeout is above 0, the server holds the request. This wait is a long poll. It ends when a message arrives for any client of the session, or when the time is up. The answer then holds the messages queued for this client, or an empty messages array when there are none, as when the message was for another client of the session. A client that closes the connection first gets no answer.

A client loop is: call with after=0, then call again with after set to each response's last_id. Passing an earlier after reads the entries again. Queue entries are deleted after QUEUE_MAX_AGE (default 30 days), not when read.

POST /api/v1/messages: send a command

Needs auth. The body is a message envelope with at least command:

{ "command": "PRIVMSG", "to": "#general", "body": ["hello world"] }

The server reads command, to, body and meta and ignores the rest. command is case-insensitive. Commands describes each command, its fields, its replies and its HTTP response. A command the server does not know gets ERR_UNKNOWNCOMMAND (421) in the queue and {"status": "error"}.

Errors at the HTTP level:

  • 400 invalid request body: not JSON, or too large.
  • 400 command required.
  • 401 not registered.
  • 500 internal error.

GET /api/v1/history: past messages

Needs auth. Query parameters:

  • target (required): a channel name, or the caller's own nick.
  • before (optional): the id of a message in this history. Only older messages are returned.
  • limit (default 50): at most this many messages, from 1 to 500. Any other value means 50.

Response 200: an array of PRIVMSG messages, oldest first. Only PRIVMSG is kept in history; events and NOTICE are not.

For a channel, the caller must be a member. For a nick, only the caller's own nick is allowed. The result is then the direct messages sent to the caller's session, under any nick it has held. A session that takes a released nick sees none of the messages sent to the nick's earlier holder. A channel created again after it was deleted starts with no history.

To page back, pass the id of the oldest message of a page as before. An empty array means there is nothing older.

Errors:

  • 400 target required.
  • 400 unknown before id: no PRIVMSG with that id in this history.
  • 403 not a member of this channel.
  • 403 forbidden: target is another user's nick.
  • 404 channel not found.

Example:

curl -s -b cookies.txt "http://localhost:8080/api/v1/history?target=%23general&limit=50"
curl -s -b cookies.txt "http://localhost:8080/api/v1/history?target=%23general&before=<id>&limit=50"

GET /api/v1/channels: all channels

Needs auth. Response 200: every channel on the server, sorted by name, secret (+s) channels included:

[
    { "id": 1, "name": "#general", "topic": "Welcome!" },
    { "id": 2, "name": "#dev", "topic": "" }
]

GET /api/v1/channels/{name}/members: a channel's members

Needs auth. {name} is the channel name without the #. Response 200:

[
    {
        "id": 1,
        "nick": "alice",
        "username": "alice",
        "hostname": "host.example.com",
        "isOperator": true,
        "isVoiced": false,
        "lastSeen": "2026-02-10T20:00:00Z"
    }
]

id is the member's session number. lastSeen is the time of the session's last request. An unknown channel gets 404 channel not found.

POST /api/v1/logout: remove this client

Needs auth. Deletes the calling client and clears its cookie. Response 200 {"status": "ok"}.

When the session has no other client, the session ends as it does on QUIT. Its channel peers see the reason Connection closed.

GET /api/v1/server: server information

No auth. Response 200:

{
    "name": "My Server",
    "version": "<version>",
    "motd": "Welcome!",
    "users": 42,
    "hashcash_bits": 20
}
  • name: SERVER_NAME. An empty string when it is not set.
  • version: the version compiled into the binary. An empty string in a binary built without one.
  • motd: the message of the day.
  • users: the number of sessions.
  • hashcash_bits: the difficulty a session stamp needs. Absent when the server requires no stamp.

GET /.well-known/healthcheck.json: health check

No auth. Response 200:

{
    "status": "ok",
    "now": "2026-02-10T12:00:00.123456789Z",
    "uptimeSeconds": 3600,
    "uptimeHuman": "1h0m0s",
    "version": "<version>",
    "appname": "neoirc",
    "maintenanceMode": false,
    "sessions": 42,
    "clients": 85,
    "queuedLines": 128,
    "channels": 7,
    "connectionsSinceBoot": 200,
    "sessionsSinceBoot": 150,
    "messagesSinceBoot": 5000
}
  • sessions, clients, channels: current counts.
  • queuedLines: the number of entries in all client queues.
  • connectionsSinceBoot: sessions created and logins over the HTTP API since the server started.
  • sessionsSinceBoot: sessions created over the HTTP API since the server started.
  • messagesSinceBoot: PRIVMSG and NOTICE commands with a target and a body received over the HTTP API since the server started, delivered or refused.
  • maintenanceMode: the value of MAINTENANCE_MODE.

The Docker image's HEALTHCHECK calls this endpoint every 30 seconds.

GET /metrics: Prometheus metrics

Served only when METRICS_USERNAME is set, behind HTTP basic auth with METRICS_USERNAME and METRICS_PASSWORD. The server records a request metric for every HTTP request when metrics are on.

Other paths

Every other GET path serves the web client. A path that names one of its files gets that file; any other path gets its index.html. An unknown path under /api/v1/ gets a plain-text 404. A request whose method the path does not take gets 405 with no body.

The message envelope

Every message in a queue, and every command a client sends, is one JSON object with these fields:

  • id: the message's UUID. The server assigns it and ignores one sent by a client.
  • command: the command name, such as PRIVMSG, or the name of a numeric reply, such as RPL_WELCOME. The server upper-cases what a client sends.
  • code: the number of a numeric reply, such as 1 for RPL_WELCOME or 433 for ERR_NICKNAMEINUSE. Absent on other messages.
  • from: the sender's nick, or the server name on numeric replies. The server sets it and ignores one sent by a client.
  • to: the target, a channel name or a nick; * on a WALLOPS event. Absent on NICK and QUIT events.
  • params: an array of strings with further parameters. KICK events and many numeric replies have it. The server ignores one sent by a client.
  • body: an array of strings. For chat it is the lines of text, one string per line. A body is never a bare string. Every numeric reply has a body; a few, such as RPL_CHANNELMODEIS, carry one empty string.
  • ts: when the server stored the message: UTC, RFC 3339 with fractional seconds, such as 2026-02-10T20:00:00.123456789Z; trailing zeros are dropped. The server assigns it.
  • meta: an object. A client may put anything in it. On a PRIVMSG or NOTICE the server stores and relays it unchanged, and {} when the client sent none. On every other command the server drops it, and the event carries {}. The server reads one key: meta.hashcash on a PRIVMSG to a channel with mode +H.

Commands

A client sends a command as the body of POST /api/v1/messages. For each command this section gives:

  • the fields it takes;
  • what the server does;
  • the event other users receive;
  • the numeric replies the sender receives;
  • the HTTP response.

Every numeric reply goes into the sender's own queue only. Unless stated otherwise, an error numeric comes with the HTTP response {"status": "error"}.

PRIVMSG: send a message

{"command": "PRIVMSG", "to": "#general", "body": ["hello world"]}
{"command": "PRIVMSG", "to": "#general", "body": ["line one", "line two"]}
{"command": "PRIVMSG", "to": "bob", "body": ["hey"]}
  • to (required): a channel name, or a nick for a direct message.
  • body (required): one or more lines.
  • meta (optional): relayed unchanged. A PRIVMSG to a +H channel carries its stamp in meta.hashcash.

A channel message goes to every member of the channel, the sender included: every client of the sender's session sees it in its queue. The sender must be a member. A direct message goes to the target's session and to the sender's own session, so every client of both sees it.

The message as the recipients receive it:

{
    "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5",
    "command": "PRIVMSG",
    "from": "alice",
    "to": "#general",
    "body": ["hello world"],
    "ts": "2026-02-10T20:00:00.123456789Z",
    "meta": {}
}

Replies and errors:

  • ERR_NORECIPIENT (411): no to.
  • ERR_NOTEXTTOSEND (412): no body, or an empty one.
  • ERR_NOSUCHCHANNEL (403): the channel does not exist.
  • ERR_CANNOTSENDTOCHAN (404): the message is not allowed. The body says why:
    • the sender is not a member;
    • the sender is banned (+b);
    • the channel is moderated (+m) and the sender has neither +o nor +v;
    • the channel is +H and the message has no valid hashcash stamp.
  • ERR_NOSUCHNICK (401): no session has the target nick.
  • RPL_AWAY (301): the target of a direct message is away. The message is still delivered. params holds the nick and body the away message.

HTTP response: {"id": "<message id>", "status": "sent"}.

NOTICE: send a notice

The same as PRIVMSG, with three differences:

  • The server sends no RPL_AWAY for a notice.
  • A notice to a +H channel needs no hashcash stamp.
  • A notice is not kept in history (see GET /api/v1/history).

A client that receives a NOTICE must not answer it automatically. That is the IRC rule that keeps two bots from answering each other forever.

JOIN: join a channel

{"command": "JOIN", "to": "#general"}
{"command": "JOIN", "to": "general"}
{"command": "JOIN", "to": "#private", "body": ["secretkey"]}
  • to (required): the channel. The server adds a missing #. The name must then be # followed by 1 to 63 letters, digits, _ or -.
  • body[0] (optional): the key of a +k channel.

A channel that does not exist is created with modes +nt and no topic. The joining user becomes its channel operator. Every member, the joiner included, receives the event:

{ "command": "JOIN", "from": "alice", "to": "#general", "body": ["#general"] }

The joiner then receives the topic, as RPL_TOPIC (332) with RPL_TOPICWHOTIME (333) or as RPL_NOTOPIC (331), and the member list, as RPL_NAMREPLY (353) and RPL_ENDOFNAMES (366). A JOIN to a channel the user is already in is checked, broadcast and answered again; the membership does not change.

Errors:

  • ERR_NEEDMOREPARAMS (461): no to.
  • ERR_NOSUCHCHANNEL (403): the name is not allowed.
  • ERR_BANNEDFROMCHAN (474): a ban matches the user's hostmask.
  • ERR_INVITEONLYCHAN (473): the channel is +i and the user has no invite.
  • ERR_BADCHANNELKEY (475): the channel is +k and the key is missing or wrong.
  • ERR_CHANNELISFULL (471): the channel has its +l number of members.

None of the last four applies to the user who creates the channel.

HTTP response: {"status": "joined", "channel": "#general"}.

PART: leave a channel

{"command": "PART", "to": "#general"}
{"command": "PART", "to": "#general", "body": ["goodbye"]}
  • to (required): the channel. The server adds a missing #.
  • body[0] (optional): the reason.

Every member, the leaver included, receives the event. Its body holds the reason, or one empty string:

{ "command": "PART", "from": "alice", "to": "#general", "body": ["goodbye"] }

The channel is deleted when it has no members left.

Errors:

  • ERR_NEEDMOREPARAMS (461): no to.
  • ERR_NOSUCHCHANNEL (403): the channel does not exist.
  • ERR_NOTONCHANNEL (442): the user is not a member.

HTTP response: {"status": "parted", "channel": "#general"}.

NICK: change nick

{ "command": "NICK", "body": ["newnick"] }

body[0] is the new nick. Every user who shares a channel with the user, and the user's own session, receives one event. from is the old nick and body[0] the new one; the event has no to:

{ "command": "NICK", "from": "oldnick", "body": ["newnick"] }

A NICK to the nick the session already has changes nothing and sends no event.

Errors:

  • ERR_NEEDMOREPARAMS (461): no body.
  • ERR_ERRONEUSNICKNAME (432): the nick is not allowed.
  • ERR_NICKNAMEINUSE (433): the nick is in use.

HTTP response: {"status": "ok", "nick": "newnick"}.

PASS: set the session password

{ "command": "PASS", "body": ["mypassword"] }

body[0] is the password, 8 to 72 bytes. The server stores a bcrypt hash of it. From then on POST /api/v1/login with the nick and the password adds a client to the session. A second PASS replaces the password. The password disappears with the session.

Error: ERR_NEEDMOREPARAMS (461). Its body is ["Not enough parameters"] for a missing password and ["Password must be 8 to 72 bytes"] for a wrong length.

HTTP response: {"status": "ok"}.

TOPIC: set a channel's topic

{ "command": "TOPIC", "to": "#general", "body": ["Welcome to #general"] }
  • to (required): the channel. The server adds a missing #.
  • body (required): the topic. Several strings are joined with spaces.

There is no topic query over the HTTP API; GET /api/v1/state and the JOIN replies carry the topic. Every member receives the event:

{
    "command": "TOPIC",
    "from": "alice",
    "to": "#general",
    "body": ["Welcome to #general"]
}

The setter also receives RPL_TOPIC (332) and RPL_TOPICWHOTIME (333).

Errors:

  • ERR_NEEDMOREPARAMS (461): no to, or no body.
  • ERR_NOSUCHCHANNEL (403): the channel does not exist.
  • ERR_NOTONCHANNEL (442): the user is not a member.
  • ERR_CHANOPRIVSNEEDED (482): the channel is +t and the user is not a channel operator. New channels are +t.

HTTP response: {"status": "ok", "topic": "Welcome to #general"}.

QUIT: end the session

{"command": "QUIT"}
{"command": "QUIT", "body": ["leaving"]}

The session ends:

  • The user leaves every channel. A channel left empty is deleted.
  • The nick is released.
  • The cookie of every client of the session stops working.
  • The password is gone.

Every user who shared a channel receives the event; the quitting user does not. body[0] is the reason, Client quit when none was given:

{ "command": "QUIT", "from": "alice", "body": ["leaving"] }

HTTP response: {"status": "quit"}, and the cookie is cleared.

PING: keepalive

{ "command": "PING" }

The answer is the HTTP response itself, not a queued message:

{ "command": "PONG", "from": "neoirc" }

from is the server name.

MODE: query or change modes

A to that starts with # names a channel; any other to is a nick. Channel modes and User modes explain each mode.

{"command": "MODE", "to": "#general"}
{"command": "MODE", "to": "#general", "body": ["+m"]}
{"command": "MODE", "to": "#general", "body": ["+o", "bob"]}
{"command": "MODE", "to": "#general", "body": ["+k", "secretkey"]}
{"command": "MODE", "to": "alice"}
{"command": "MODE", "to": "alice", "body": ["+w"]}

Channel query. With no body, anyone, member or not, receives RPL_CHANNELMODEIS (324) and RPL_CREATIONTIME (329). The mode string lists the set flags out of nimst, then k, l and H with their values after it, such as +ntkl secretkey 50:

{"command": "RPL_CHANNELMODEIS", "code": 324, "to": "alice", "params": ["#general", "+nt"], "body": [""]}
{"command": "RPL_CREATIONTIME", "code": 329, "to": "alice", "params": ["#general", "1709251200"], "body": [""]}

Channel change. Only a channel operator can change a channel's modes. body[0] is one change: a sign and one letter. body[1] is its value where the mode takes one. A string of several letters, such as +mt, is not accepted. The changes:

  • +o <nick> / -o <nick>: give or take channel operator.
  • +v <nick> / -v <nick>: give or take voice.
  • +i / -i: invite-only on or off.
  • +m / -m: moderated on or off.
  • +s / -s: secret on or off.
  • +t / -t: topic lock on or off.
  • +k <key> / -k: set or clear the channel key.
  • +l <number> / -l: set or clear the member limit. The number must be a positive whole number.
  • +b <mask> / -b <mask>: add or remove a ban. +b with no mask lists the bans; anyone may do that.
  • +H <bits> / -H: require a hashcash stamp of bits leading zero bits on every PRIVMSG, from 1 to 40, or stop requiring one.

Every member, the operator included, receives a MODE event. For o, v, i, m, s and t its body is one string: ["+o bob"], ["+m"]. For k, l and b it is the change and its value as separate strings: ["+k", "secretkey"], ["-k", "*"], ["+l", "50"], ["-l"], ["+b", "*!*@*.example.com"].

{ "command": "MODE", "from": "alice", "to": "#general", "body": ["+o bob"] }

A +H or -H change sends no event. The operator receives RPL_CHANNELMODEIS (324) with the new mode string.

Listing bans sends one RPL_BANLIST (367) per ban, with params [channel, mask, nick who set it, Unix time], then RPL_ENDOFBANLIST (368).

Errors of a channel MODE:

  • ERR_NEEDMOREPARAMS (461): no to, or +o, -o, +v, -v, +k, +l, -b or +H without its value.
  • ERR_NOSUCHCHANNEL (403): the channel does not exist.
  • ERR_CHANOPRIVSNEEDED (482): a change by a user who is not a channel operator.
  • ERR_UNKNOWNMODE (472), in three cases:
    • a change the list above does not have;
    • a +H value that is not a whole number from 1 to 40;
    • a +l value that is not a positive whole number.
  • ERR_NOSUCHNICK (401): the +o, -o, +v or -v nick does not exist.
  • ERR_USERNOTINCHANNEL (441): that nick is not a member.

User modes. A user can query and change only their own modes; any other nick gets ERR_USERSDONTMATCH (502). With no body, the server sends RPL_UMODEIS (221) with the modes in body: +, +o, +w or +ow. With a body, body[0] is a mode string starting with + or -, such as +w or -o. w can be set and unset. o can only be unset; OPER sets it. A string with any other letter, or without a letter, gets ERR_UMODEUNKNOWNFLAG (501) and changes nothing. An accepted change is answered with RPL_UMODEIS (221) and the new modes, and nobody else is told.

{ "command": "RPL_UMODEIS", "code": 221, "to": "alice", "body": ["+w"] }

HTTP response: {"status": "ok"}, or {"status": "error"} with an error numeric.

INVITE: invite a user

{ "command": "INVITE", "body": ["bob", "#private"] }

body[0] is the nick and body[1] the channel; the server adds a missing #. The inviter must be a member. On a +i channel the inviter must be a channel operator. The invite lets the user join a +i channel once; the join uses it up.

The inviter receives RPL_INVITING (341) with params [nick, channel]. The invited user receives the event, and nobody else does:

{ "command": "INVITE", "from": "alice", "to": "bob", "body": ["#private"] }

Errors:

  • ERR_NEEDMOREPARAMS (461): fewer than two strings in body.
  • ERR_NOSUCHCHANNEL (403): the channel does not exist.
  • ERR_NOTONCHANNEL (442): the inviter is not a member.
  • ERR_CHANOPRIVSNEEDED (482): the channel is +i and the inviter is not a channel operator.
  • ERR_NOSUCHNICK (401): no session has the nick.
  • ERR_USERONCHANNEL (443): the user is already a member.

HTTP response: {"status": "ok"}.

KICK: remove a user from a channel

{ "command": "KICK", "to": "#general", "body": ["bob", "misbehaving"] }
  • to (required): the channel. The server adds a missing #.
  • body[0] (required): the nick to remove.
  • body[1] (optional): the reason. Default: the kicker's nick.

Only a channel operator can kick. Every member, the kicked user included, receives the event. The kicker is in from, the channel in to, the kicked nick in params and the reason in body:

{
    "command": "KICK",
    "from": "alice",
    "to": "#general",
    "params": ["bob"],
    "body": ["misbehaving"]
}

The channel is deleted when it has no members left.

Errors:

  • ERR_NEEDMOREPARAMS (461): no to, or no body.
  • ERR_NOSUCHCHANNEL (403): the channel does not exist.
  • ERR_CHANOPRIVSNEEDED (482): the kicker is not a channel operator.
  • ERR_NOSUCHNICK (401): no session has the nick.
  • ERR_USERNOTINCHANNEL (441): the nick is not a member.

HTTP response: {"status": "ok"}.

AWAY: set or clear an away message

{"command": "AWAY", "body": ["back in an hour"]}
{"command": "AWAY"}

A body sets the away message; several strings are joined with spaces. No body clears it. The reply is RPL_NOWAWAY (306) or RPL_UNAWAY (305). A user who sends a PRIVMSG to an away user receives RPL_AWAY (301). USERHOST marks an away user with -.

HTTP response: {"status": "ok"}.

NAMES: a channel's members

{ "command": "NAMES", "to": "#general" }

The server adds a missing #. The reply is RPL_NAMREPLY (353), when the channel has members, then RPL_ENDOFNAMES (366). RPL_NAMREPLY has params ["=", channel] and a body with one string: the members' hostmasks separated by spaces, each with @ before a channel operator and + before a voiced member:

{
    "command": "RPL_NAMREPLY",
    "code": 353,
    "to": "alice",
    "params": ["=", "#general"],
    "body": ["@op1!op1@host1 alice!alice@host2 +bob!bob@host3"]
}

Errors:

  • ERR_NEEDMOREPARAMS (461): no to.
  • ERR_NOSUCHCHANNEL (403): the channel does not exist.

HTTP response: {"status": "ok"}.

LIST: all channels

{ "command": "LIST" }

The reply is one RPL_LIST (322) per channel, with params [channel, member count] and the topic in body, then RPL_LISTEND (323). Secret (+s) channels the user is not a member of are left out.

HTTP response: {"status": "ok"}.

WHOIS: information about a user

{"command": "WHOIS", "to": "bob"}
{"command": "WHOIS", "body": ["bob"]}

The nick comes from to, or from body[0] when to is empty. The replies, in order:

  • RPL_WHOISUSER (311): params [nick, username, hostname, "*"], body [nick].
  • RPL_WHOISSERVER (312): params [nick, server name].
  • RPL_WHOISOPERATOR (313): only when the user is a server operator.
  • RPL_WHOISIDLE (317): params [nick, seconds since the user's last request, Unix time of the session's creation].
  • RPL_WHOISCHANNELS (319): body with the user's channels separated by spaces. Secret channels the asker is not in are left out. Absent when there is none to show.
  • RPL_WHOISACTUALLY (338): only when the asker is a server operator. params [nick, IP address] and body ["is actually using host <hostname>"], for the user's newest client.
  • RPL_ENDOFWHOIS (318).

An unknown nick gets ERR_NOSUCHNICK (401) and RPL_ENDOFWHOIS (318). No nick at all gets ERR_NEEDMOREPARAMS (461).

HTTP response: {"status": "ok"}, also for an unknown nick.

WHO: a channel's members, one reply each

{ "command": "WHO", "to": "#general" }

The server adds a missing #. The reply is one RPL_WHOREPLY (352) per member, with params [channel, username, hostname, server name, nick, "H"] and body ["0 <nick>"], then RPL_ENDOFWHO (315). An unknown channel gets RPL_ENDOFWHO alone. No to gets ERR_NEEDMOREPARAMS (461).

HTTP response: {"status": "ok"}.

LUSERS: user and channel counts

{ "command": "LUSERS" }

The replies are RPL_LUSERCLIENT (251), RPL_LUSEROP (252), RPL_LUSERCHANNELS (254) and RPL_LUSERME (255). The server sends them to every new client too.

HTTP response: {"status": "ok"}.

MOTD: the message of the day

{ "command": "MOTD" }

The server sends the welcome replies 001 to 005, the LUSERS replies and the message of the day again: RPL_MOTDSTART (375), one RPL_MOTD (372) per line, RPL_ENDOFMOTD (376). With an empty MOTD setting, the welcome and LUSERS replies come alone.

HTTP response: {"status": "ok"}.

USERHOST: hostmasks of up to five users

{ "command": "USERHOST", "body": ["alice", "bob"] }

The body holds up to five nicks; further ones are ignored. The reply is RPL_USERHOST (302) with one string in body: for each nick that exists, nick=+username@hostname, separated by spaces. A * after the nick marks a server operator. A - instead of the + marks an away user. Unknown nicks are left out.

Error: ERR_NEEDMOREPARAMS (461) without a body.

HTTP response: {"status": "ok"}.

VERSION, ADMIN, INFO, TIME: server details

Each takes no fields.

  • VERSION: RPL_VERSION (351) with params ["neoirc-<version>.", server name].
  • ADMIN: RPL_ADMINME (256) with params [server name], then RPL_ADMINLOC1 (257) neoirc server, RPL_ADMINLOC2 (258) IRC over HTTP and RPL_ADMINEMAIL (259) admin@<server name>.
  • INFO: four RPL_INFO (371) lines, naming the program, its version, its language and its start time, then RPL_ENDOFINFO (374).
  • TIME: RPL_TIME (391) with params [server name] and the server's time in body, in RFC 1123 form.

HTTP response: {"status": "ok"}.

OPER: become a server operator

{ "command": "OPER", "body": ["opername", "operpassword"] }

body[0] and body[1] are compared with NEOIRC_OPER_NAME and NEOIRC_OPER_PASSWORD. On success the session gets user mode +o and RPL_YOUREOPER (381). The status lasts until the session ends or the user sends MODE <nick> -o.

Errors:

  • ERR_NEEDMOREPARAMS (461): fewer than two strings in body.
  • ERR_NOOPERHOST (491): the name or password is wrong, or either setting is empty.

HTTP response: {"status": "ok"}.

KILL: end another user's session

{ "command": "KILL", "body": ["bob", "spamming"] }

Only a server operator can kill. body[0] is the nick and body[1] the reason, KILLed when none is given. The target's session ends as with QUIT. Its channel peers receive a QUIT event with the reason Killed (<operator nick> (<reason>)). A target connected through the IRC listener is sent KILL and ERROR lines and disconnected.

Errors:

  • ERR_NEEDMOREPARAMS (461): no nick.
  • ERR_NOPRIVILEGES (481): the sender is not a server operator.
  • ERR_NOSUCHNICK (401): no session has the nick.
  • ERR_CANTKILLSERVER (483): the nick is the operator's own.

HTTP response: {"status": "ok"}.

WALLOPS: message every user with +w

{ "command": "WALLOPS", "body": ["server restarts in five minutes"] }

Only a server operator can send it. The strings of body are joined with spaces. Every user with user mode +w receives the event, with to *:

{
    "command": "WALLOPS",
    "from": "alice",
    "to": "*",
    "body": ["server restarts in five minutes"]
}

Errors:

  • ERR_NEEDMOREPARAMS (461): no body.
  • ERR_NOPRIVILEGES (481): the sender is not a server operator.

HTTP response: {"status": "ok"}.

Numeric replies

A numeric reply is a message from the server to one client. Over the HTTP API, command holds the reply's name and code its number; from is the server name and to the client's nick. 005 arrives as RPL_BOUNCE, its name in RFC 2812. Every reply has a body with one string, the reply's text; a few carry an empty string.

Sent to every new client, on POST /api/v1/session, POST /api/v1/login and MOTD; 375 to 376 only when MOTD is set:

  • 001 RPL_WELCOME: Welcome to the network, <nick>.
  • 002 RPL_YOURHOST: Your host is <server name>, running version <version>.
  • 003 RPL_CREATED: This server was created <date the server started>.
  • 004 RPL_MYINFO: params [server name, version, "", "ikmnostl"].
  • 005 RPL_BOUNCE: params ["CHANTYPES=#", "NICKLEN=32", "PREFIX=(ov)@+", "CHANMODES=b,k,Hl,imnst", "NETWORK=neoirc", "CASEMAPPING=ascii"], body ["are supported by this server"].
  • 251 RPL_LUSERCLIENT: There are <sessions> users and 0 invisible on 1 servers.
  • 252 RPL_LUSEROP: params [server operators], body ["operator(s) online"].
  • 254 RPL_LUSERCHANNELS: params [channels], body ["channels formed"].
  • 255 RPL_LUSERME: I have <sessions> clients and 1 servers.
  • 375 RPL_MOTDSTART: - <server name> Message of the Day -.
  • 372 RPL_MOTD: one per line of the message of the day, each - <line>.
  • 376 RPL_ENDOFMOTD: End of /MOTD command.

Answers to commands, as described under each command:

  • 221 RPL_UMODEIS: user MODE.
  • 256 RPL_ADMINME, 257 RPL_ADMINLOC1, 258 RPL_ADMINLOC2, 259 RPL_ADMINEMAIL: ADMIN.
  • 301 RPL_AWAY: a PRIVMSG to an away user.
  • 302 RPL_USERHOST: USERHOST.
  • 305 RPL_UNAWAY, 306 RPL_NOWAWAY: AWAY.
  • 311 RPL_WHOISUSER, 312 RPL_WHOISSERVER, 313 RPL_WHOISOPERATOR, 317 RPL_WHOISIDLE, 318 RPL_ENDOFWHOIS, 319 RPL_WHOISCHANNELS, 338 RPL_WHOISACTUALLY: WHOIS.
  • 315 RPL_ENDOFWHO, 352 RPL_WHOREPLY: WHO.
  • 322 RPL_LIST, 323 RPL_LISTEND: LIST.
  • 324 RPL_CHANNELMODEIS, 329 RPL_CREATIONTIME: a channel MODE query; 324 also after +H and -H.
  • 331 RPL_NOTOPIC, 332 RPL_TOPIC, 333 RPL_TOPICWHOTIME: JOIN, TOPIC and the channel state after login or ?initChannelState=1. 333 has params [channel, nick who set the topic, Unix time].
  • 341 RPL_INVITING: INVITE.
  • 351 RPL_VERSION: VERSION.
  • 353 RPL_NAMREPLY, 366 RPL_ENDOFNAMES: JOIN, NAMES and the channel state after login or ?initChannelState=1.
  • 367 RPL_BANLIST, 368 RPL_ENDOFBANLIST: MODE #channel +b.
  • 371 RPL_INFO, 374 RPL_ENDOFINFO: INFO.
  • 381 RPL_YOUREOPER: OPER.
  • 391 RPL_TIME: TIME.

Errors, each with what its params holds:

  • 401 ERR_NOSUCHNICK: no session has that nick. params: [nick].
  • 403 ERR_NOSUCHCHANNEL: no such channel, or a name JOIN does not allow. params: [channel].
  • 404 ERR_CANNOTSENDTOCHAN: a channel message that is not allowed. params: [channel].
  • 411 ERR_NORECIPIENT: PRIVMSG or NOTICE without to. params: [command].
  • 412 ERR_NOTEXTTOSEND: PRIVMSG or NOTICE without text. params: [command].
  • 421 ERR_UNKNOWNCOMMAND: a command the server does not know. params: [command].
  • 432 ERR_ERRONEUSNICKNAME: a nick that is not allowed. params: [nick].
  • 433 ERR_NICKNAMEINUSE: a nick in use. params: [nick].
  • 441 ERR_USERNOTINCHANNEL: the nick in a KICK, or in a MODE +o, -o, +v or -v, is not a member. params: [nick, channel].
  • 442 ERR_NOTONCHANNEL: the sender is not a member. params: [channel].
  • 443 ERR_USERONCHANNEL: an INVITE of a member. params: [nick, channel].
  • 461 ERR_NEEDMOREPARAMS: a required field is missing. params: [command].
  • 471 ERR_CHANNELISFULL, 473 ERR_INVITEONLYCHAN, 474 ERR_BANNEDFROMCHAN, 475 ERR_BADCHANNELKEY: a refused JOIN. params: [channel].
  • 472 ERR_UNKNOWNMODE: a channel mode change that is not accepted. params: the change, such as ["+x"].
  • 481 ERR_NOPRIVILEGES: KILL or WALLOPS by a user who is not a server operator. No params.
  • 482 ERR_CHANOPRIVSNEEDED: an action only a channel operator may take. params: [channel].
  • 483 ERR_CANTKILLSERVER: a KILL of oneself. No params.
  • 491 ERR_NOOPERHOST: a failed OPER. No params.
  • 501 ERR_UMODEUNKNOWNFLAG: a user mode string that is not accepted. No params.
  • 502 ERR_USERSDONTMATCH: a user MODE for another nick. No params.

Channel modes

A channel's modes are set with MODE (see MODE). A new channel has +n and +t.

  • +b <mask>: ban. A user whose hostmask matches the mask cannot join or send. The mask is nick!user@host with * for any run of characters and ? for one character; the match ignores letter case. A channel can have any number of bans.
  • +i: invite-only. Only a user with an invite can join. On a +i channel only a channel operator can invite.
  • +k <key>: channel key. A JOIN must carry the key in body[0].
  • +l <number>: member limit. A JOIN that would exceed it is refused.
  • +m: moderated. Only channel operators and voiced members can send.
  • +n: no external messages. New channels have it. The IRC listener can set and clear it; the HTTP API cannot. It changes nothing: nobody who is not a member can send to a channel, with or without it.
  • +s: secret. LIST and WHOIS leave the channel out for users who are not members. GET /api/v1/channels still shows it.
  • +t: topic lock. Only a channel operator can set the topic.
  • +H <bits>: hashcash. Every PRIVMSG needs a stamp of at least bits leading zero bits (see Hashcash). An IRC listener client cannot send a PRIVMSG to the channel; its NOTICE still goes through.

Two modes belong to a member rather than the channel:

  • +o: channel operator, shown as @ before the name in RPL_NAMREPLY.
  • +v: voice, shown as +. A voiced member can send on a moderated channel.

User modes

  • +o: server operator. OPER sets it; MODE <nick> -o clears it.
  • +w: receives WALLOPS. MODE <nick> +w sets it and MODE <nick> -w clears it.

Hostmasks

Every session has a hostmask in IRC's form, nick!username@hostname:

  • nick: the session's current nick.
  • username: the username sent at session creation, or the nick.
  • hostname: the reverse DNS name of the IP address the session was created from, or that IP address when the lookup gives no name. The lookup waits up to three seconds.

Over the HTTP API, the server takes the client's IP address from the first entry of the X-Forwarded-For header, then X-Real-IP, then the TCP connection (see Reverse proxy).

Every user can see a hostmask: in RPL_WHOISUSER (311), RPL_WHOREPLY (352), RPL_USERHOST (302), RPL_NAMREPLY (353) and GET /api/v1/channels/{name}/members. Bans match against it. Only a server operator sees IP addresses, in RPL_WHOISACTUALLY (338), and only that of the user's newest client. When the lookup gave no name, the hostname everyone sees is the IP address.

Hashcash

Hashcash is a proof of work. The client finds a string whose SHA-256 hash starts with a given number of zero bits, and the server checks the hash. Finding one takes work that doubles with each bit; checking takes one hash. neoirc uses it in two places.

Session stamps

Creating a session over the HTTP API needs a stamp unless NEOIRC_HASHCASH_BITS is 0. GET /api/v1/server tells the difficulty in hashcash_bits. The stamp has six fields separated by colons:

1:bits:date:resource::counter
  • 1: the version. Always 1.
  • bits: the difficulty the stamp claims. It must be at least the server's hashcash_bits.
  • date: the current date in UTC, as YYMMDD or YYMMDDHHMMSS.
  • resource: the server name, as name in GET /api/v1/server, or neoirc when that is empty.
  • An empty field.
  • counter: a hex number the client varies until the hash fits.

The SHA-256 hash of the whole stamp string must start with at least hashcash_bits zero bits. Example: 1:20:260310:neoirc::3a2f1b. In pseudocode:

counter = 0
loop:
    stamp = "1:20:" + date + ":neoirc::" + hex(counter)
    if leading_zero_bits(sha256(stamp)) >= 20: return stamp
    counter = counter + 1

At 20 bits this takes about a million hashes, around a second on a current computer. Each further bit doubles the work.

The server accepts a stamp when all of these hold:

  • the version is 1;
  • bits is at least the required difficulty;
  • resource is the server name;
  • the date is at most 48 hours in the past and at most one hour in the future;
  • the hash has the zero bits;
  • the stamp has not been spent.

Spent stamps are kept in memory for 48 hours; a restart forgets them. The client sends the stamp as pow_token in POST /api/v1/session. The web client and neoirc-cli compute it on their own.

Channel stamps

A channel with mode +H needs a stamp on every PRIVMSG, in meta.hashcash. The stamp is bound to the channel and to the message:

1:bits:date:channel:bodyhash:counter
  • channel: the channel name, with the #.
  • bodyhash: the SHA-256 hash, in lowercase hex, of the message's body value, byte for byte as the request spells it.

The version, bits and date rules of session stamps apply to channel stamps too, with the channel's +H value as the difficulty.

Example:

{
    "command": "PRIVMSG",
    "to": "#general",
    "body": ["hello world"],
    "meta": { "hashcash": "1:20:260317:#general:a1b2c3...:1f4a" }
}

The server checks the stamp after membership, bans and +m, so a message refused for one of those does not spend it. It then records the stamp's hash in the database and refuses the stamp for a year. A missing, invalid or spent stamp gets ERR_CANNOTSENDTOCHAN (404), with the reason in body. NOTICE needs no stamp. The web client and neoirc-cli do not compute channel stamps.

Login rate limit

POST /api/v1/login is limited per IP address: LOGIN_RATE_LIMIT requests per second (default 1) with a burst of LOGIN_RATE_BURST (default 5). A request over the limit gets 429 too many login attempts, try again later with Retry-After: 1. Every 10 minutes the server forgets the addresses whose last attempt is more than 15 minutes old. Session creation has no rate limit; the hashcash stamp is its cost.

The IP address comes from X-Forwarded-For or X-Real-IP when present. Without a reverse proxy that sets those headers, a client can set them itself and so pick any address (see Reverse proxy).

Writing a client

A complete client needs four requests:

  1. POST /api/v1/session to get a cookie, with a stamp if the server requires one.
  2. POST /api/v1/messages with JOIN for each channel.
  3. GET /api/v1/messages in a loop, with timeout=15 and after set to the last last_id.
  4. POST /api/v1/messages to send anything else.

A loop in Python, against a server started with NEOIRC_HASHCASH_BITS=0:

import requests, time

BASE = "http://localhost:8080/api/v1"
session = requests.Session()

resp = session.post(f"{BASE}/session", json={"nick": "pybot"})
# requests sends a Secure cookie only over HTTPS. For a plain-HTTP
# server on localhost, send it by hand on every request.
session.headers["Cookie"] = f"neoirc_auth={resp.cookies['neoirc_auth']}"

session.post(f"{BASE}/messages", json={"command": "JOIN", "to": "#general"})

last_id = 0
while True:
    try:
        resp = session.get(f"{BASE}/messages",
                           params={"after": last_id, "timeout": 15},
                           timeout=20)  # longer than the long-poll timeout
        data = resp.json()
        last_id = data.get("last_id", last_id)
        for msg in data.get("messages", []):
            print(f"[{msg['command']}] <{msg.get('from', '')}> "
                  f"{' '.join(msg.get('body', []))}")
    except requests.exceptions.Timeout:
        continue
    except Exception as exc:
        print(f"error: {exc}")
        time.sleep(2)

The same loop in a browser, where the cookie is sent automatically:

async function pollLoop() {
    let lastId = 0;
    while (true) {
        try {
            const resp = await fetch(
                `/api/v1/messages?after=${lastId}&timeout=15`,
                { credentials: "same-origin" },
            );
            if (resp.status === 401) break; // the session is gone
            const data = await resp.json();
            lastId = data.last_id;
            for (const msg of data.messages) handleMessage(msg);
        } catch (e) {
            await new Promise((r) => setTimeout(r, 2000));
        }
    }
}

Advice for client authors:

  • Set the HTTP timeout above the long-poll timeout. With timeout=15, give the request at least 20 seconds.
  • Keep after moving. Reset it to 0 only to read the queue again.
  • Expect your own messages. The server echoes a session's own PRIVMSG and NOTICE to all its clients. It does the same with the events of its JOIN, PART, NICK, TOPIC, MODE and KICK. Show them, or drop them by id.
  • Key a direct-message window by the other user's nick: to when from is you, from otherwise.
  • On 401, the session is gone or the cookie is wrong. Create a new session, or log in again when the session has a password.
  • Show a message by its command:
    • PRIVMSG as <nick> text;
    • NOTICE as -nick- text;
    • JOIN as *** nick has joined #channel;
    • NICK as *** oldnick is now known as newnick;
    • TOPIC as *** nick set topic: text;
    • a numeric reply by its body.

The IRC listener

The server also accepts plain IRC connections, as in RFC 1459 and RFC 2812, on IRC_LISTEN_ADDR (default :6667). Standard IRC clients connect to it. IRC listener users and HTTP API users share the same channels, nicks and messages: the listener and the HTTP API use the same database and the same queues. The listener has no TLS.

Connecting

A line ends in LF, with or without a CR before it, and is at most 512 bytes long, the line ending included. A longer line ends the connection. A connection registers by sending NICK and USER. Registering creates a session with the connection as its only client; no hashcash stamp is needed. The hostname is the reverse DNS name of the connection's address, looked up when it connects, or the address itself when the lookup gives no name. Before registration the server accepts only PASS, NICK, USER, PING, PONG, QUIT and CAP; anything else gets ERR_NOTREGISTERED (451).

A PASS before registration sets the new session's password, as the PASS command does over the HTTP API. It does not log in to an existing session. A password of the wrong length is refused with a NOTICE once registration completes.

After registration the client receives 001 to 005, the LUSERS replies and the message of the day, or ERR_NOMOTD (422) when MOTD is empty. The 004 modes are mnst and the 005 tokens CHANTYPES=#, NICKLEN=32, PREFIX=(ov)@+, CHANMODES=,,H,imnst and NETWORK=<server name>.

CAP LS is answered with an empty capability list; other CAP subcommands are ignored.

When a connection has sent nothing for 90 seconds, the server sends PING. When nothing arrives in the 30 seconds after that, the server closes the connection. Any line, such as the client's PONG, starts both waits again.

Closing the connection, with or without QUIT, ends the session, as QUIT does over the HTTP API. Its channel peers see a QUIT with the reason Connection closed; the reason given to QUIT is not relayed. Every command after registration counts as activity for the idle cleanup.

Commands

The listener accepts these commands:

  • Connection: NICK, USER, PASS, QUIT, PING, PONG, CAP.
  • Channels: JOIN, PART, MODE, TOPIC, NAMES, LIST, KICK, INVITE.
  • Messages: PRIVMSG, NOTICE.
  • Information: WHO, WHOIS, LUSERS, MOTD, AWAY, USERHOST, VERSION, ADMIN, INFO, TIME.
  • Server operator: OPER, KILL, WALLOPS.

They behave as over the HTTP API, with the IRC syntax and these differences:

  • JOIN and PART take several channels separated by commas. JOIN #a,#b keya,keyb gives each channel its key. JOIN adds a missing #; other commands use the name as given.
  • TOPIC #channel without a topic queries it.
  • MODE #channel <modes> [values...] takes several letters in one string, such as +mt-i or +ov alice bob. It accepts i, m, n, s, t, o, v and H, but not b, k or l. A letter whose value is missing is skipped; any other letter gets ERR_UNKNOWNMODE (472). The changes made go to every member as one MODE line, such as +o-v alice bob.
  • A PRIVMSG to a +H channel always gets ERR_CANNOTSENDTOCHAN (404): an IRC line cannot carry a stamp.
  • A NOTICE to an away user gets RPL_AWAY (301), as a PRIVMSG does.
  • RPL_NAMREPLY (353) lists nicks, not hostmasks.
  • LIST starts with RPL_LISTSTART (321).
  • WHOIS sends RPL_WHOISCHANNELS (319) with @ and + before the channel names, then RPL_WHOISIDLE (317), then RPL_AWAY (301) for an away user. It never sends RPL_WHOISACTUALLY (338). WHOIS with two parameters uses the second as the nick. No parameter gets ERR_NONICKNAMEGIVEN (431).
  • WHO <nick> answers for one user. RPL_WHOREPLY (352) flags are H@ for a channel operator and H+ for a voiced member.
  • NAMES and WHO without a parameter, or for an unknown channel, get only the end reply.
  • NICK without a nick gets ERR_NONICKNAMEGIVEN (431).
  • KICK without a reason uses the kicked user's nick as the reason.

What IRC clients see

Each line of a multi-line body reaches an IRC client as its own PRIVMSG or NOTICE line. A CR, LF or NUL inside a line is removed first. Other users appear with the prefix nick!nick@*. A mode change made over the HTTP API arrives as an IRC MODE line with each value as its own parameter, such as MODE #general +k secretkey. An INVITE arrives as :alice!alice@* INVITE bob #general. Numeric replies, KICK, TOPIC, NICK, QUIT (as Quit: <reason>) and WALLOPS arrive in their IRC forms.

The listener does not queue a client's own commands back to it. It sends the echo itself, as IRC does, and for PRIVMSG and NOTICE sends none. Other clients of the same session do see them. A WALLOPS from a client with +w does come back through its queue.

An operator's KILL of an IRC listener user sends the user KILL and ERROR :Closing Link and closes the connection, whichever transport the KILL came from.

The web client

The server serves a web client at /. It is a single page written with Preact, built into the binary from web/src/. It uses the HTTP API with the browser's cookie. On loading it calls GET /api/v1/state?initChannelState=1 to rebuild its channel tabs. When the server requires a hashcash stamp, it computes one with the browser's Web Crypto API before creating a session.

neoirc-cli

neoirc-cli is a terminal client for the HTTP API. The Docker build compiles it to check that it builds and leaves it out of the image. The Makefile has no target for it. Build it from the source tree:

go build -o bin/neoirc-cli ./cmd/neoirc-cli

It starts disconnected. Commands start with /:

  • /connect <url>: create a session on the server at <url>, such as http://localhost:8080, with the current nick. The default nick is guest. The client fetches GET /api/v1/server first and computes a hashcash stamp when the server needs one.
  • /nick <name>: change the nick, or set the nick to use before connecting.
  • /join #channel, /part [#channel].
  • /msg <nick> <text>: send a direct message. /query <nick>: open a window for a user.
  • /topic <text>: set the current channel's topic.
  • /names: the members of the current channel. /list: all channels. /who [#channel], /whois <nick>, /motd.
  • /window <n>, /w <n> or Alt and a digit: switch to window n. Window 0 is the status window.
  • /quit: send QUIT and exit. /help: the command list.

Text without a / goes as a PRIVMSG to the current target. /join and /query set the target to the channel or user they open. /window and /w set it to the channel or user of the window they switch to; a switch to window 0 leaves it as it was. Alt and a digit switches the window shown and leaves the target as it was. /part of the target's channel clears it.

The client reads its queue with timeout=15 and retries after two seconds on an error. It sends no hashcash stamps for +H channels. It sends the auth cookie to a server on localhost over plain HTTP, and otherwise only over HTTPS.

Running the server

Configuration

Every setting is an environment variable. The server also reads a .env file in its working directory, and a YAML file neoirc.yaml in /etc/neoirc/ or $HOME/.config/neoirc/ with the same names as keys. An environment variable wins over the file. An environment variable set to the empty string counts as unset, except IRC_LISTEN_ADDR, where the empty string turns the IRC listener off. A neoirc.yaml that is not valid YAML stops the server at startup.

  • PORT (default 8080): the HTTP port.
  • IRC_LISTEN_ADDR (default :6667): the address of the IRC listener. The empty string turns the listener off.
  • DBURL (default file:///var/lib/neoirc/state.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)): the SQLite database. A DBURL of your own should keep both _pragma parameters: write-ahead logging, and a wait of 5000 ms for a locked database. It must not set foreign_keys; the server adds _pragma=foreign_keys(1) itself and refuses to start otherwise. See The database.
  • SERVER_NAME (default empty): the server's name in replies, in GET /api/v1/server and as the hashcash resource. When empty, replies and the hashcash resource use neoirc, and GET /api/v1/server returns an empty string.
  • MOTD (default: a built-in banner): the message of the day. Lines are separated by newlines.
  • NEOIRC_HASHCASH_BITS (default 20): the difficulty of the stamp POST /api/v1/session needs. 0 turns the requirement off.
  • NEOIRC_OPER_NAME and NEOIRC_OPER_PASSWORD (default empty): the name and password OPER accepts. OPER fails while either is empty.
  • MAX_MESSAGE_SIZE (default 4096): the largest request body, in bytes, for POST /api/v1/session, POST /api/v1/login and POST /api/v1/messages.
  • LOGIN_RATE_LIMIT (default 1) and LOGIN_RATE_BURST (default 5): login attempts allowed per second per IP address, and the burst above that rate. A value of 0 or less means the default.
  • SESSION_IDLE_TIMEOUT (default 720h, 30 days): a Go duration such as 720h or 24h. A client with no request for this long is removed, and a session left with no client ends. See Data lifecycle. A value that is not a duration is logged, and the default applies.
  • MESSAGE_MAX_AGE (default 720h): messages older than this are deleted. A value that is not a duration is logged, and no message is deleted.
  • QUEUE_MAX_AGE (default 720h): queue entries older than this are deleted. A value that is not a duration is logged, and no entry is deleted.
  • DEBUG (default false): true turns on debug logging.
  • SENTRY_DSN (default empty): when set, panics in HTTP requests are reported to Sentry.
  • METRICS_USERNAME and METRICS_PASSWORD (default empty): the HTTP basic auth credentials of GET /metrics. The endpoint exists only when METRICS_USERNAME is set.
  • MAINTENANCE_MODE (default false): reported as maintenanceMode in the health check. Nothing else reads it.

An example .env:

PORT=8080
SERVER_NAME=My Server
MOTD=Welcome! Be excellent to each other.
DBURL=file:///var/lib/neoirc/state.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)
SESSION_IDLE_TIMEOUT=720h
NEOIRC_HASHCASH_BITS=20

Docker

make docker builds the image, tagged neoirc. The image:

  • is Alpine with the neoircd binary and CA certificates;
  • runs as the user neoirc;
  • exposes ports 8080 and 6667;
  • keeps its database in /var/lib/neoirc;
  • has a HEALTHCHECK that calls /.well-known/healthcheck.json every 30 seconds.

Run it with a volume for the database and the settings as environment variables:

docker run -d --name neoirc \
  -p 8080:8080 -p 6667:6667 \
  -v neoirc-data:/var/lib/neoirc \
  -e SERVER_NAME="My Server" \
  -e MOTD="Welcome!" \
  neoirc

Reverse proxy

Run the server behind a reverse proxy that terminates TLS. The server itself speaks plain HTTP, and the auth cookie is Secure, so browsers send it only over HTTPS. The proxy must:

  • Set X-Forwarded-For or X-Real-IP to the client's address, replacing any value the client sent. The server trusts these headers from anyone. Without a proxy that overwrites them, a client can choose its own address, which sets its hostname and lets it dodge the login rate limit.
  • Wait at least 60 seconds for a response. A long poll can take 30 seconds, and the server's own write timeout is 60 seconds.

Caddy:

neoirc.example.com {
    reverse_proxy localhost:8080
}

nginx:

server {
    listen 443 ssl;
    server_name neoirc.example.com;

    ssl_certificate /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_read_timeout 60s;
    }
}

The IRC listener has no TLS.

Limits and headers

  • The server reads a request within 10 seconds and writes a response within 60 seconds. A request is cancelled after 60 seconds and gets 504 with no body. Headers may be up to 1 MiB.
  • A long poll waits at most 30 seconds.
  • Every response to a well-formed request, except to a cross-origin preflight, carries a Content-Security-Policy header: default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'. The web client works within it.
  • Cross-origin requests are allowed from any origin, with credentials. The allowed methods are GET, POST, PUT, DELETE and OPTIONS. The allowed request headers are Accept, Content-Type and X-CSRF-Token. The cookie's SameSite=Strict still keeps it to the same site.

Logging

The server logs with Go's slog, one line per event, to standard output. The lines are JSON, or plain text when standard output is a terminal. Every HTTP request is logged with its method, URL, status, latency, remote address, user agent and request ID. DEBUG=true adds debug-level lines.

The database

The server stores everything in one SQLite database, through the pure Go driver modernc.org/sqlite. It uses one database connection, so reads and writes take turns. On startup it creates the tables from internal/db/schema/001_initial.sql if they do not exist, and records the schema version in schema_migrations.

The tables:

  • sessions: one row per session: nick, username, hostname, IP address, flags, password hash, away message and timestamps.
  • clients: one row per client: the SHA-256 hash of its cookie, its IP address and hostname, its creation time and the time of its last request.
  • channels: name, topic, who set the topic and when, every channel mode and the creation time.
  • channel_members: which sessions are in which channel, with their +o and +v flags.
  • channel_bans and channel_invites: the bans and the pending invites of each channel.
  • messages: every message once, with the session or channel it was sent to.
  • client_queues: one row per queued message per client. Its row number is the last_id of GET /api/v1/messages.
  • spent_hashcash: the hashes of channel stamps already used.

Back up the database with sqlite3 /var/lib/neoirc/state.db ".backup backup.db". Or stop the server and copy state.db together with state.db-wal, if that file exists: in write-ahead logging mode it holds recent writes not yet in state.db.

Do not set DBURL to an in-memory database (file::memory:). SQLite deletes an in-memory database when its last connection closes, and the server's connection pool replaces its connection whenever a query is cancelled. Everything stored until then is lost.

Data lifecycle

  • A channel is deleted when its last member leaves, is kicked, quits or is removed by the idle cleanup.
  • A session ends when any of these happens:
    • QUIT or KILL;
    • its last client logs out;
    • the connection of an IRC listener user closes;
    • the idle cleanup removes its last client.
  • With the session go its nick, its password and its channel memberships. Its messages stay until they age out.
  • A session's last request time is updated on every authenticated HTTP request and on every command on the IRC listener.
  • A cleanup runs every half SESSION_IDLE_TIMEOUT, but at least a minute apart. With the default that is every 15 days, the first time 15 days after the server starts. It deletes clients whose last request is older than SESSION_IDLE_TIMEOUT and ends the sessions left with no client; their channel peers see a QUIT with the reason Connection closed. It deletes messages older than MESSAGE_MAX_AGE, queue entries older than QUEUE_MAX_AGE and channel hashcash stamps older than a year. A row can therefore outlive its age limit by up to one cleanup interval.

Shutdown

On SIGINT or SIGTERM the server stops. It waits up to five seconds for HTTP requests in progress, and up to five seconds for the IRC connections to close and their sessions to end. Then it closes the database.

Development

Layout

  • cmd/neoircd/: the server's main, which wires the packages with fx.
  • cmd/neoirc-cli/: the terminal client's main.
  • internal/config/: the settings, read with Viper.
  • internal/server/: the HTTP server, its routes and its middleware.
  • internal/handlers/: the HTTP API handlers and the cleanup loop.
  • internal/service/: the command logic the HTTP API and the IRC listener share.
  • internal/ircserver/: the IRC listener: connections, the line parser, and the relay that turns queued messages into IRC lines.
  • internal/db/: the database: connection, migrations, queries, and the schema in schema/.
  • internal/broker/: wakes waiting long polls and IRC relays when a session gets a message.
  • internal/hashcash/: stamp checking and minting.
  • internal/ratelimit/: the per-address login rate limiter.
  • internal/middleware/: request logging, cross-origin requests, the Content-Security-Policy header and metrics.
  • internal/healthcheck/ and internal/stats/: the health check and its counters.
  • internal/logger/ and internal/globals/: logging setup and build information.
  • internal/cli/: the terminal client, with its API client in api/.
  • internal/dbtest/: a database per test; only tests import it.
  • pkg/irc/: the IRC command names and numeric reply codes.
  • web/: the web client's source, build script and embed.go.
  • schema/: JSON Schema files for the message envelope, with their own README.md.
  • script/: the entrypoints below.
  • CONVENTIONS.md, REPO_POLICIES.md and AGENTS.md: the architecture conventions, the repository policies and the rules for coding agents.

Entrypoints

This repository adheres to the Scripts to Rule Them All standard. Each script has a make target of the same name, except script/install-precommit, which is make hooks, and script/cibuild, script/precommit and script/projectname, which have none.

  • script/bootstrap: installs what development needs: make, git, Node and yarn (for prettier) and Go.
  • script/setup: makes a fresh clone ready: runs script/bootstrap, then script/install-precommit.
  • script/test: builds the Dockerfile's test stage, which runs the tests with -race.
  • script/lint: builds the Dockerfile's lint stage, which runs golangci-lint.
  • script/fmt: formats the Go code with gofmt and the Markdown with prettier.
  • script/fmt-check: the same, read-only; fails if anything would change.
  • script/check: runs script/test, script/lint and script/fmt-check.
  • script/docker: builds the image, tagged neoirc.
  • script/cibuild: the CI build: script/bootstrap, script/check, then the image.
  • script/precommit: run by the git pre-commit hook; fails if go mod tidy changes go.mod or go.sum, then runs script/check.
  • script/install-precommit: installs that hook.
  • script/projectname: prints the project name, neoirc.

Building and running from source

  • make build: compiles bin/neoircd. When web/dist/ does not exist, it creates empty placeholder files there, so the binary serves an empty page at /. To include the web client, build it first: in web/, run yarn install --frozen-lockfile and sh build.sh.
  • make run: builds and starts the server. make debug does the same with DEBUG=1.
  • make clean: removes bin/.
  • make check: runs script/check. make test, make lint, make fmt and make fmt-check run the script of the same name.
  • make docker: builds the image.
  • make hooks: installs the pre-commit hook.

Tests and checks

The tests and the linter run inside Docker, as stages of the Dockerfile, so every developer and the CI run the same toolchain. make test builds the test stage: go test -race over every package, with a 90-second limit. make lint builds the lint stage, which runs golangci-lint with the shared .golangci.yml. make fmt-check runs gofmt and prettier on the host. Tests live next to the code, most in _test packages. A test that needs a database gets an in-memory one of its own from internal/dbtest, which holds a second connection open to keep it.

The Dockerfile has five stages:

  1. web-builder: installs the web client's Node dependencies and bundles web/src/app.jsx with esbuild into web/dist/.
  2. lint: runs golangci-lint, with placeholder files in web/dist/ so that it does not wait for the web build.
  3. test: runs the tests, with the same placeholders.
  4. builder: runs only after lint and test have passed. It compiles neoircd with the real web client, and compiles neoirc-cli to check that it builds.
  5. The runtime stage: Alpine with neoircd.

The CI workflow in .gitea/workflows/check.yml runs script/cibuild on every push.

TODO

Open work is tracked on the issue tracker at https://git.eeqj.de/sneak/neoirc/issues.

License

MIT. See LICENSE.

Author

@sneak

S
Description
No description provided
Readme MIT
1.7 MiB
Languages
Go 91.6%
JavaScript 5%
Shell 1.6%
CSS 1%
Dockerfile 0.5%
Other 0.2%