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
74 KiB
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
- Why it exists
- Getting started
- How it works
- Using the HTTP API
- The IRC listener
- The web client
- neoirc-cli
- Running the server
- Development
- TODO
- License
- Author
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
POSTrequest and reads its messages with oneGETrequest. - 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:
PRIVMSGandNOTICE. 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,INVITEandWALLOPS, telling the client what other users did. When a client's ownJOIN,PART,NICK,TOPIC,MODEorKICKcauses an event, every client of its session receives it too. - Numeric replies: the server's answers to commands, such as
RPL_TOPICorERR_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_authcookie.POST /api/v1/sessionandPOST /api/v1/loginset it. A request without a valid cookie gets401with{"error": "not registered", "numeric": 451}. - An endpoint in this section answers an error at the HTTP level with a
4xxor5xxstatus and{"error": "<text>"}. - An error in a command is not an HTTP error. The request gets
200with{"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/loginandPOST /api/v1/messagesare limited toMAX_MESSAGE_SIZEbytes (default4096). A larger body gets400invalid 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.
The auth cookie
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 withNEOIRC_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:
400invalid request body: not JSON, or too large.400invalid nick format.400invalid username format.402hashcash proof-of-work required: nopow_token, and the server requires one.402invalid hashcash stamp: <reason>.409nick 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
JOINevent from the session's own nick with an emptybody; - the topic replies;
- the member list.
A client can rebuild its channel windows from them.
Errors:
429too many login attempts, try again later, withRetry-After: 1. See Login rate limit.400invalid request body.400nick and password required.401invalid 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(default0): return only queue entries with a number greater than this. Pass thelast_idof the previous response.timeout(default0): how many seconds to wait for a message when the queue has none.0answers at once. The server caps it at30.
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:
400invalid request body: not JSON, or too large.400command required.401not registered.500internal error.
GET /api/v1/history: past messages
Needs auth. Query parameters:
target(required): a channel name, or the caller's own nick.before(optional): theidof a message in this history. Only older messages are returned.limit(default50): 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:
400target required.400unknown before id: noPRIVMSGwith thatidin this history.403not a member of this channel.403forbidden:targetis another user's nick.404channel 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:PRIVMSGandNOTICEcommands with a target and a body received over the HTTP API since the server started, delivered or refused.maintenanceMode: the value ofMAINTENANCE_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 asPRIVMSG, or the name of a numeric reply, such asRPL_WELCOME. The server upper-cases what a client sends.code: the number of a numeric reply, such as1forRPL_WELCOMEor433forERR_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 aWALLOPSevent. Absent onNICKandQUITevents.params: an array of strings with further parameters.KICKevents 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. Abodyis never a bare string. Every numeric reply has abody; a few, such asRPL_CHANNELMODEIS, carry one empty string.ts: when the server stored the message: UTC, RFC 3339 with fractional seconds, such as2026-02-10T20:00:00.123456789Z; trailing zeros are dropped. The server assigns it.meta: an object. A client may put anything in it. On aPRIVMSGorNOTICEthe 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.hashcashon aPRIVMSGto 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. APRIVMSGto a+Hchannel carries its stamp inmeta.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): noto.ERR_NOTEXTTOSEND(412): nobody, or an empty one.ERR_NOSUCHCHANNEL(403): the channel does not exist.ERR_CANNOTSENDTOCHAN(404): the message is not allowed. Thebodysays why:- the sender is not a member;
- the sender is banned (
+b); - the channel is moderated (
+m) and the sender has neither+onor+v; - the channel is
+Hand 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.paramsholds the nick andbodythe 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_AWAYfor a notice. - A notice to a
+Hchannel 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+kchannel.
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): noto.ERR_NOSUCHCHANNEL(403): the name is not allowed.ERR_BANNEDFROMCHAN(474): a ban matches the user's hostmask.ERR_INVITEONLYCHAN(473): the channel is+iand the user has no invite.ERR_BADCHANNELKEY(475): the channel is+kand the key is missing or wrong.ERR_CHANNELISFULL(471): the channel has its+lnumber 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): noto.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): nobody.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): noto, or nobody.ERR_NOSUCHCHANNEL(403): the channel does not exist.ERR_NOTONCHANNEL(442): the user is not a member.ERR_CHANOPRIVSNEEDED(482): the channel is+tand 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.+bwith no mask lists the bans; anyone may do that.+H <bits>/-H: require a hashcash stamp ofbitsleading zero bits on everyPRIVMSG, 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): noto, or+o,-o,+v,-v,+k,+l,-bor+Hwithout 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
+Hvalue that is not a whole number from 1 to 40; - a
+lvalue that is not a positive whole number.
ERR_NOSUCHNICK(401): the+o,-o,+vor-vnick 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 inbody.ERR_NOSUCHCHANNEL(403): the channel does not exist.ERR_NOTONCHANNEL(442): the inviter is not a member.ERR_CHANOPRIVSNEEDED(482): the channel is+iand 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): noto, or nobody.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): noto.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):bodywith 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]andbody["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) withparams["neoirc-<version>.", server name].ADMIN:RPL_ADMINME(256) withparams[server name], thenRPL_ADMINLOC1(257)neoirc server,RPL_ADMINLOC2(258)IRC over HTTPandRPL_ADMINEMAIL(259)admin@<server name>.INFO: fourRPL_INFO(371) lines, naming the program, its version, its language and its start time, thenRPL_ENDOFINFO(374).TIME:RPL_TIME(391) withparams[server name]and the server's time inbody, 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 inbody.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): nobody.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:
001RPL_WELCOME:Welcome to the network, <nick>.002RPL_YOURHOST:Your host is <server name>, running version <version>.003RPL_CREATED:This server was created <date the server started>.004RPL_MYINFO:params[server name, version, "", "ikmnostl"].005RPL_BOUNCE:params["CHANTYPES=#", "NICKLEN=32", "PREFIX=(ov)@+", "CHANMODES=b,k,Hl,imnst", "NETWORK=neoirc", "CASEMAPPING=ascii"],body["are supported by this server"].251RPL_LUSERCLIENT:There are <sessions> users and 0 invisible on 1 servers.252RPL_LUSEROP:params[server operators],body["operator(s) online"].254RPL_LUSERCHANNELS:params[channels],body["channels formed"].255RPL_LUSERME:I have <sessions> clients and 1 servers.375RPL_MOTDSTART:- <server name> Message of the Day -.372RPL_MOTD: one per line of the message of the day, each- <line>.376RPL_ENDOFMOTD:End of /MOTD command.
Answers to commands, as described under each command:
221RPL_UMODEIS: userMODE.256RPL_ADMINME,257RPL_ADMINLOC1,258RPL_ADMINLOC2,259RPL_ADMINEMAIL:ADMIN.301RPL_AWAY: aPRIVMSGto an away user.302RPL_USERHOST:USERHOST.305RPL_UNAWAY,306RPL_NOWAWAY:AWAY.311RPL_WHOISUSER,312RPL_WHOISSERVER,313RPL_WHOISOPERATOR,317RPL_WHOISIDLE,318RPL_ENDOFWHOIS,319RPL_WHOISCHANNELS,338RPL_WHOISACTUALLY:WHOIS.315RPL_ENDOFWHO,352RPL_WHOREPLY:WHO.322RPL_LIST,323RPL_LISTEND:LIST.324RPL_CHANNELMODEIS,329RPL_CREATIONTIME: a channelMODEquery;324also after+Hand-H.331RPL_NOTOPIC,332RPL_TOPIC,333RPL_TOPICWHOTIME:JOIN,TOPICand the channel state after login or?initChannelState=1.333hasparams[channel, nick who set the topic, Unix time].341RPL_INVITING:INVITE.351RPL_VERSION:VERSION.353RPL_NAMREPLY,366RPL_ENDOFNAMES:JOIN,NAMESand the channel state after login or?initChannelState=1.367RPL_BANLIST,368RPL_ENDOFBANLIST:MODE #channel +b.371RPL_INFO,374RPL_ENDOFINFO:INFO.381RPL_YOUREOPER:OPER.391RPL_TIME:TIME.
Errors, each with what its params holds:
401ERR_NOSUCHNICK: no session has that nick.params:[nick].403ERR_NOSUCHCHANNEL: no such channel, or a nameJOINdoes not allow.params:[channel].404ERR_CANNOTSENDTOCHAN: a channel message that is not allowed.params:[channel].411ERR_NORECIPIENT:PRIVMSGorNOTICEwithoutto.params:[command].412ERR_NOTEXTTOSEND:PRIVMSGorNOTICEwithout text.params:[command].421ERR_UNKNOWNCOMMAND: a command the server does not know.params:[command].432ERR_ERRONEUSNICKNAME: a nick that is not allowed.params:[nick].433ERR_NICKNAMEINUSE: a nick in use.params:[nick].441ERR_USERNOTINCHANNEL: the nick in aKICK, or in aMODE+o,-o,+vor-v, is not a member.params:[nick, channel].442ERR_NOTONCHANNEL: the sender is not a member.params:[channel].443ERR_USERONCHANNEL: anINVITEof a member.params:[nick, channel].461ERR_NEEDMOREPARAMS: a required field is missing.params:[command].471ERR_CHANNELISFULL,473ERR_INVITEONLYCHAN,474ERR_BANNEDFROMCHAN,475ERR_BADCHANNELKEY: a refusedJOIN.params:[channel].472ERR_UNKNOWNMODE: a channel mode change that is not accepted.params: the change, such as["+x"].481ERR_NOPRIVILEGES:KILLorWALLOPSby a user who is not a server operator. Noparams.482ERR_CHANOPRIVSNEEDED: an action only a channel operator may take.params:[channel].483ERR_CANTKILLSERVER: aKILLof oneself. Noparams.491ERR_NOOPERHOST: a failedOPER. Noparams.501ERR_UMODEUNKNOWNFLAG: a user mode string that is not accepted. Noparams.502ERR_USERSDONTMATCH: a userMODEfor another nick. Noparams.
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 isnick!user@hostwith*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+ichannel only a channel operator can invite.+k <key>: channel key. AJOINmust carry the key inbody[0].+l <number>: member limit. AJOINthat 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.LISTandWHOISleave the channel out for users who are not members.GET /api/v1/channelsstill shows it.+t: topic lock. Only a channel operator can set the topic.+H <bits>: hashcash. EveryPRIVMSGneeds a stamp of at leastbitsleading zero bits (see Hashcash). An IRC listener client cannot send aPRIVMSGto the channel; itsNOTICEstill goes through.
Two modes belong to a member rather than the channel:
+o: channel operator, shown as@before the name inRPL_NAMREPLY.+v: voice, shown as+. A voiced member can send on a moderated channel.
User modes
+o: server operator.OPERsets it;MODE <nick> -oclears it.+w: receivesWALLOPS.MODE <nick> +wsets it andMODE <nick> -wclears it.
Hostmasks
Every session has a hostmask in IRC's form, nick!username@hostname:
nick: the session's current nick.username: theusernamesent 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. Always1.bits: the difficulty the stamp claims. It must be at least the server'shashcash_bits.date: the current date in UTC, asYYMMDDorYYMMDDHHMMSS.resource: the server name, asnameinGET /api/v1/server, orneoircwhen 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; bitsis at least the required difficulty;resourceis 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'sbodyvalue, 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:
POST /api/v1/sessionto get a cookie, with a stamp if the server requires one.POST /api/v1/messageswithJOINfor each channel.GET /api/v1/messagesin a loop, withtimeout=15andafterset to the lastlast_id.POST /api/v1/messagesto 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
aftermoving. Reset it to0only to read the queue again. - Expect your own messages. The server echoes a session's own
PRIVMSGandNOTICEto all its clients. It does the same with the events of itsJOIN,PART,NICK,TOPIC,MODEandKICK. Show them, or drop them byid. - Key a direct-message window by the other user's nick:
towhenfromis you,fromotherwise. - 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:PRIVMSGas<nick> text;NOTICEas-nick- text;JOINas*** nick has joined #channel;NICKas*** oldnick is now known as newnick;TOPICas*** 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:
JOINandPARTtake several channels separated by commas.JOIN #a,#b keya,keybgives each channel its key.JOINadds a missing#; other commands use the name as given.TOPIC #channelwithout a topic queries it.MODE #channel <modes> [values...]takes several letters in one string, such as+mt-ior+ov alice bob. It acceptsi,m,n,s,t,o,vandH, but notb,korl. A letter whose value is missing is skipped; any other letter getsERR_UNKNOWNMODE(472). The changes made go to every member as oneMODEline, such as+o-v alice bob.- A
PRIVMSGto a+Hchannel always getsERR_CANNOTSENDTOCHAN(404): an IRC line cannot carry a stamp. - A
NOTICEto an away user getsRPL_AWAY(301), as aPRIVMSGdoes. RPL_NAMREPLY(353) lists nicks, not hostmasks.LISTstarts withRPL_LISTSTART(321).WHOISsendsRPL_WHOISCHANNELS(319) with@and+before the channel names, thenRPL_WHOISIDLE(317), thenRPL_AWAY(301) for an away user. It never sendsRPL_WHOISACTUALLY(338).WHOISwith two parameters uses the second as the nick. No parameter getsERR_NONICKNAMEGIVEN(431).WHO <nick>answers for one user.RPL_WHOREPLY(352) flags areH@for a channel operator andH+for a voiced member.NAMESandWHOwithout a parameter, or for an unknown channel, get only the end reply.NICKwithout a nick getsERR_NONICKNAMEGIVEN(431).KICKwithout 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 ashttp://localhost:8080, with the current nick. The default nick isguest. The client fetchesGET /api/v1/serverfirst 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 windown. Window0is the status window./quit: sendQUITand 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(default8080): the HTTP port.IRC_LISTEN_ADDR(default:6667): the address of the IRC listener. The empty string turns the listener off.DBURL(defaultfile:///var/lib/neoirc/state.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)): the SQLite database. ADBURLof your own should keep both_pragmaparameters: write-ahead logging, and a wait of 5000 ms for a locked database. It must not setforeign_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, inGET /api/v1/serverand as the hashcash resource. When empty, replies and the hashcash resource useneoirc, andGET /api/v1/serverreturns an empty string.MOTD(default: a built-in banner): the message of the day. Lines are separated by newlines.NEOIRC_HASHCASH_BITS(default20): the difficulty of the stampPOST /api/v1/sessionneeds.0turns the requirement off.NEOIRC_OPER_NAMEandNEOIRC_OPER_PASSWORD(default empty): the name and passwordOPERaccepts.OPERfails while either is empty.MAX_MESSAGE_SIZE(default4096): the largest request body, in bytes, forPOST /api/v1/session,POST /api/v1/loginandPOST /api/v1/messages.LOGIN_RATE_LIMIT(default1) andLOGIN_RATE_BURST(default5): login attempts allowed per second per IP address, and the burst above that rate. A value of0or less means the default.SESSION_IDLE_TIMEOUT(default720h, 30 days): a Go duration such as720hor24h. 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(default720h): messages older than this are deleted. A value that is not a duration is logged, and no message is deleted.QUEUE_MAX_AGE(default720h): queue entries older than this are deleted. A value that is not a duration is logged, and no entry is deleted.DEBUG(defaultfalse):trueturns on debug logging.SENTRY_DSN(default empty): when set, panics in HTTP requests are reported to Sentry.METRICS_USERNAMEandMETRICS_PASSWORD(default empty): the HTTP basic auth credentials ofGET /metrics. The endpoint exists only whenMETRICS_USERNAMEis set.MAINTENANCE_MODE(defaultfalse): reported asmaintenanceModein 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
neoircdbinary and CA certificates; - runs as the user
neoirc; - exposes ports 8080 and 6667;
- keeps its database in
/var/lib/neoirc; - has a
HEALTHCHECKthat calls/.well-known/healthcheck.jsonevery 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-FororX-Real-IPto 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
504with 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-Policyheader: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,DELETEandOPTIONS. The allowed request headers areAccept,Content-TypeandX-CSRF-Token. The cookie'sSameSite=Strictstill 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+oand+vflags.channel_bansandchannel_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 thelast_idofGET /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:
QUITorKILL;- 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 thanSESSION_IDLE_TIMEOUTand ends the sessions left with no client; their channel peers see aQUITwith the reasonConnection closed. It deletes messages older thanMESSAGE_MAX_AGE, queue entries older thanQUEUE_MAX_AGEand 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'smain, which wires the packages withfx.cmd/neoirc-cli/: the terminal client'smain.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 inschema/.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, theContent-Security-Policyheader and metrics.internal/healthcheck/andinternal/stats/: the health check and its counters.internal/logger/andinternal/globals/: logging setup and build information.internal/cli/: the terminal client, with its API client inapi/.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 andembed.go.schema/: JSON Schema files for the message envelope, with their ownREADME.md.script/: the entrypoints below.CONVENTIONS.md,REPO_POLICIES.mdandAGENTS.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 andyarn(for prettier) and Go.script/setup: makes a fresh clone ready: runsscript/bootstrap, thenscript/install-precommit.script/test: builds theDockerfile'steststage, which runs the tests with-race.script/lint: builds theDockerfile'slintstage, which runs golangci-lint.script/fmt: formats the Go code withgofmtand the Markdown with prettier.script/fmt-check: the same, read-only; fails if anything would change.script/check: runsscript/test,script/lintandscript/fmt-check.script/docker: builds the image, taggedneoirc.script/cibuild: the CI build:script/bootstrap,script/check, then the image.script/precommit: run by the git pre-commit hook; fails ifgo mod tidychangesgo.modorgo.sum, then runsscript/check.script/install-precommit: installs that hook.script/projectname: prints the project name,neoirc.
Building and running from source
make build: compilesbin/neoircd. Whenweb/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: inweb/, runyarn install --frozen-lockfileandsh build.sh.make run: builds and starts the server.make debugdoes the same withDEBUG=1.make clean: removesbin/.make check: runsscript/check.make test,make lint,make fmtandmake fmt-checkrun 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:
web-builder: installs the web client's Node dependencies and bundlesweb/src/app.jsxwith esbuild intoweb/dist/.lint: runs golangci-lint, with placeholder files inweb/dist/so that it does not wait for the web build.test: runs the tests, with the same placeholders.builder: runs only afterlintandtesthave passed. It compilesneoircdwith the real web client, and compilesneoirc-clito check that it builds.- 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.