# neoirc neoirc is an MIT-licensed chat server written in Go by [@sneak](https://sneak.berlin). It carries IRC commands and replies as JSON over HTTP, and keeps each user's session on the server. - [What neoirc is](#what-neoirc-is) - [Why it exists](#why-it-exists) - [Getting started](#getting-started) - [How it works](#how-it-works) - [Using the HTTP API](#using-the-http-api) - [The IRC listener](#the-irc-listener) - [The web client](#the-web-client) - [neoirc-cli](#neoirc-cli) - [Running the server](#running-the-server) - [Development](#development) - [TODO](#todo) - [License](#license) - [Author](#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 `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: ```bash 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](#hashcash)). Open `http://localhost:8080/` for the web client, or talk to the API with `curl`: ```bash # 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](#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-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": ""}`. - 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. ### 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: ```json { "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](#hostmasks)). 1 to 32 characters out of letters, digits and `` _-[]\^{}|` ``. Default: the nick. - `pow_token`: a hashcash stamp (see [Hashcash](#hashcash)). Required unless the server runs with `NEOIRC_HASHCASH_BITS=0`. Response `201`, with the cookie: ```json { "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](#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: `. - `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: ```json { "nick": "alice", "password": "mypassword" } ``` Response `200`, with a new cookie for the new client: ```json { "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](#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`: ```json { "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`: ```json { "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`: ```json { "command": "PRIVMSG", "to": "#general", "body": ["hello world"] } ``` The server reads `command`, `to`, `body` and `meta` and ignores the rest. `command` is case-insensitive. [Commands](#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: ```bash 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=&limit=50" ``` #### `GET /api/v1/channels`: all channels Needs auth. Response `200`: every channel on the server, sorted by name, secret (`+s`) channels included: ```json [ { "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`: ```json [ { "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`](#quit-end-the-session). Its channel peers see the reason `Connection closed`. #### `GET /api/v1/server`: server information No auth. Response `200`: ```json { "name": "My Server", "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`: ```json { "status": "ok", "now": "2026-02-10T12:00:00.123456789Z", "uptimeSeconds": 3600, "uptimeHuman": "1h0m0s", "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 ```json {"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: ```json { "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": "", "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`](#get-apiv1history-past-messages)). 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 ```json {"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: ```json { "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 ```json {"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: ```json { "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 ```json { "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`: ```json { "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 ```json { "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 ```json { "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: ```json { "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 ```json {"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: ```json { "command": "QUIT", "from": "alice", "body": ["leaving"] } ``` HTTP response: `{"status": "quit"}`, and the cookie is cleared. #### `PING`: keepalive ```json { "command": "PING" } ``` The answer is the HTTP response itself, not a queued message: ```json { "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](#channel-modes) and [User modes](#user-modes) explain each mode. ```json {"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`: ```json {"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 ` / `-o `: give or take channel operator. - `+v ` / `-v `: 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 ` / `-k`: set or clear the channel key. - `+l ` / `-l`: set or clear the member limit. The number must be a positive whole number. - `+b ` / `-b `: add or remove a ban. `+b` with no mask lists the bans; anyone may do that. - `+H ` / `-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"]`. ```json { "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. ```json { "command": "RPL_UMODEIS", "code": 221, "to": "alice", "body": ["+w"] } ``` HTTP response: `{"status": "ok"}`, or `{"status": "error"}` with an error numeric. #### `INVITE`: invite a user ```json { "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: ```json { "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 ```json { "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`: ```json { "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 ```json {"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 ```json { "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: ```json { "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 ```json { "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 ```json {"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 "]`, 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 ```json { "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 "]`, 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 ```json { "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 ```json { "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 ```json { "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-.", 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@`. - `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 ```json { "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 -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 ```json { "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 ( ())`. 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` ```json { "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` `*`: ```json { "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, `. - `002` `RPL_YOURHOST`: `Your host is , running version `. - `003` `RPL_CREATED`: `This server was created `. - `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 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 clients and 1 servers`. - `375` `RPL_MOTDSTART`: `- Message of the Day -`. - `372` `RPL_MOTD`: one per line of the message of the day, each `- `. - `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`](#mode-query-or-change-modes)). A new channel has `+n` and `+t`. - `+b `: 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 `: channel key. A `JOIN` must carry the key in `body[0]`. - `+l `: 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 `: hashcash. Every `PRIVMSG` needs a stamp of at least `bits` leading zero bits (see [Hashcash](#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 -o` clears it. - `+w`: receives `WALLOPS`. `MODE +w` sets it and `MODE -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](#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: ```json { "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](#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`: ```python 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: ```javascript 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 ` 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=`. `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 [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 ` 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: `) 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: ```bash go build -o bin/neoirc-cli ./cmd/neoirc-cli ``` It starts disconnected. Commands start with `/`: - `/connect `: create a session on the server at ``, 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 `: change the nick, or set the nick to use before connecting. - `/join #channel`, `/part [#channel]`. - `/msg `: send a direct message. `/query `: open a window for a user. - `/topic `: set the current channel's topic. - `/names`: the members of the current channel. `/list`: all channels. `/who [#channel]`, `/whois `, `/motd`. - `/window `, `/w ` 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](#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](#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: ```bash 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: ```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](https://github.com/github/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 . ## License MIT. See `LICENSE`. ## Author [@sneak](https://sneak.berlin)