check / check (push) Waiting to run
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
1967 lines
74 KiB
Markdown
1967 lines
74 KiB
Markdown
# 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": "<text>"}`.
|
|
- An error in a command is not an HTTP error. The request gets `200` with
|
|
`{"status": "error"}`, and the reason arrives as a numeric reply in the
|
|
client's queue.
|
|
- Request bodies for `POST /api/v1/session`, `POST /api/v1/login` and
|
|
`POST /api/v1/messages` are limited to `MAX_MESSAGE_SIZE` bytes (default
|
|
`4096`). A larger body gets `400` `invalid request body`.
|
|
- The server allows cross-origin requests from any origin, with credentials. The
|
|
cookie is `SameSite=Strict`, so a browser sends it only from the same site.
|
|
|
|
### 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: <reason>`.
|
|
- `409` `nick already taken`.
|
|
|
|
The server checks the stamp before the nick, and spends it only when the session
|
|
is created. A request refused with `400` or `409` leaves the stamp unspent.
|
|
|
|
#### `POST /api/v1/login`: add a client to a session
|
|
|
|
No auth. The session must have a password, set with the `PASS` command. Request
|
|
body:
|
|
|
|
```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=<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:
|
|
|
|
```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": "<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": "<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": "<message id>", "status": "sent"}`.
|
|
|
|
#### `NOTICE`: send a notice
|
|
|
|
The same as `PRIVMSG`, with three differences:
|
|
|
|
- The server sends no `RPL_AWAY` for a notice.
|
|
- A notice to a `+H` channel needs no hashcash stamp.
|
|
- A notice is not kept in history (see
|
|
[`GET /api/v1/history`](#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 <nick>` / `-o <nick>`: give or take channel operator.
|
|
- `+v <nick>` / `-v <nick>`: give or take voice.
|
|
- `+i` / `-i`: invite-only on or off.
|
|
- `+m` / `-m`: moderated on or off.
|
|
- `+s` / `-s`: secret on or off.
|
|
- `+t` / `-t`: topic lock on or off.
|
|
- `+k <key>` / `-k`: set or clear the channel key.
|
|
- `+l <number>` / `-l`: set or clear the member limit. The number must be a
|
|
positive whole number.
|
|
- `+b <mask>` / `-b <mask>`: add or remove a ban. `+b` with no mask lists the
|
|
bans; anyone may do that.
|
|
- `+H <bits>` / `-H`: require a hashcash stamp of `bits` leading zero bits on
|
|
every `PRIVMSG`, from 1 to 40, or stop requiring one.
|
|
|
|
Every member, the operator included, receives a `MODE` event. For `o`, `v`, `i`,
|
|
`m`, `s` and `t` its `body` is one string: `["+o bob"]`, `["+m"]`. For `k`, `l`
|
|
and `b` it is the change and its value as separate strings:
|
|
`["+k", "secretkey"]`, `["-k", "*"]`, `["+l", "50"]`, `["-l"]`,
|
|
`["+b", "*!*@*.example.com"]`.
|
|
|
|
```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 <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
|
|
|
|
```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 <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
|
|
|
|
```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-<version>.", server name]`.
|
|
- `ADMIN`: `RPL_ADMINME` (`256`) with `params` `[server name]`, then
|
|
`RPL_ADMINLOC1` (`257`) `neoirc server`, `RPL_ADMINLOC2` (`258`)
|
|
`IRC over HTTP` and `RPL_ADMINEMAIL` (`259`) `admin@<server name>`.
|
|
- `INFO`: four `RPL_INFO` (`371`) lines, naming the program, its version, its
|
|
language and its start time, then `RPL_ENDOFINFO` (`374`).
|
|
- `TIME`: `RPL_TIME` (`391`) with `params` `[server name]` and the server's time
|
|
in `body`, in RFC 1123 form.
|
|
|
|
HTTP response: `{"status": "ok"}`.
|
|
|
|
#### `OPER`: become a server operator
|
|
|
|
```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 <nick> -o`.
|
|
|
|
Errors:
|
|
|
|
- `ERR_NEEDMOREPARAMS` (`461`): fewer than two strings in `body`.
|
|
- `ERR_NOOPERHOST` (`491`): the name or password is wrong, or either setting is
|
|
empty.
|
|
|
|
HTTP response: `{"status": "ok"}`.
|
|
|
|
#### `KILL`: end another user's session
|
|
|
|
```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 (<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`
|
|
|
|
```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, <nick>`.
|
|
- `002` `RPL_YOURHOST`: `Your host is <server name>, running version <version>`.
|
|
- `003` `RPL_CREATED`: `This server was created <date the server started>`.
|
|
- `004` `RPL_MYINFO`: `params` `[server name, version, "", "ikmnostl"]`.
|
|
- `005` `RPL_BOUNCE`: `params`
|
|
`["CHANTYPES=#", "NICKLEN=32", "PREFIX=(ov)@+", "CHANMODES=b,k,Hl,imnst", "NETWORK=neoirc", "CASEMAPPING=ascii"]`,
|
|
`body` `["are supported by this server"]`.
|
|
- `251` `RPL_LUSERCLIENT`:
|
|
`There are <sessions> users and 0 invisible on 1 servers`.
|
|
- `252` `RPL_LUSEROP`: `params` `[server operators]`, `body`
|
|
`["operator(s) online"]`.
|
|
- `254` `RPL_LUSERCHANNELS`: `params` `[channels]`, `body`
|
|
`["channels formed"]`.
|
|
- `255` `RPL_LUSERME`: `I have <sessions> clients and 1 servers`.
|
|
- `375` `RPL_MOTDSTART`: `- <server name> Message of the Day -`.
|
|
- `372` `RPL_MOTD`: one per line of the message of the day, each `- <line>`.
|
|
- `376` `RPL_ENDOFMOTD`: `End of /MOTD command.`
|
|
|
|
Answers to commands, as described under each command:
|
|
|
|
- `221` `RPL_UMODEIS`: user `MODE`.
|
|
- `256` `RPL_ADMINME`, `257` `RPL_ADMINLOC1`, `258` `RPL_ADMINLOC2`, `259`
|
|
`RPL_ADMINEMAIL`: `ADMIN`.
|
|
- `301` `RPL_AWAY`: a `PRIVMSG` to an away user.
|
|
- `302` `RPL_USERHOST`: `USERHOST`.
|
|
- `305` `RPL_UNAWAY`, `306` `RPL_NOWAWAY`: `AWAY`.
|
|
- `311` `RPL_WHOISUSER`, `312` `RPL_WHOISSERVER`, `313` `RPL_WHOISOPERATOR`,
|
|
`317` `RPL_WHOISIDLE`, `318` `RPL_ENDOFWHOIS`, `319` `RPL_WHOISCHANNELS`,
|
|
`338` `RPL_WHOISACTUALLY`: `WHOIS`.
|
|
- `315` `RPL_ENDOFWHO`, `352` `RPL_WHOREPLY`: `WHO`.
|
|
- `322` `RPL_LIST`, `323` `RPL_LISTEND`: `LIST`.
|
|
- `324` `RPL_CHANNELMODEIS`, `329` `RPL_CREATIONTIME`: a channel `MODE` query;
|
|
`324` also after `+H` and `-H`.
|
|
- `331` `RPL_NOTOPIC`, `332` `RPL_TOPIC`, `333` `RPL_TOPICWHOTIME`: `JOIN`,
|
|
`TOPIC` and the channel state after login or `?initChannelState=1`. `333` has
|
|
`params` `[channel, nick who set the topic, Unix time]`.
|
|
- `341` `RPL_INVITING`: `INVITE`.
|
|
- `351` `RPL_VERSION`: `VERSION`.
|
|
- `353` `RPL_NAMREPLY`, `366` `RPL_ENDOFNAMES`: `JOIN`, `NAMES` and the channel
|
|
state after login or `?initChannelState=1`.
|
|
- `367` `RPL_BANLIST`, `368` `RPL_ENDOFBANLIST`: `MODE #channel +b`.
|
|
- `371` `RPL_INFO`, `374` `RPL_ENDOFINFO`: `INFO`.
|
|
- `381` `RPL_YOUREOPER`: `OPER`.
|
|
- `391` `RPL_TIME`: `TIME`.
|
|
|
|
Errors, each with what its `params` holds:
|
|
|
|
- `401` `ERR_NOSUCHNICK`: no session has that nick. `params`: `[nick]`.
|
|
- `403` `ERR_NOSUCHCHANNEL`: no such channel, or a name `JOIN` does not allow.
|
|
`params`: `[channel]`.
|
|
- `404` `ERR_CANNOTSENDTOCHAN`: a channel message that is not allowed. `params`:
|
|
`[channel]`.
|
|
- `411` `ERR_NORECIPIENT`: `PRIVMSG` or `NOTICE` without `to`. `params`:
|
|
`[command]`.
|
|
- `412` `ERR_NOTEXTTOSEND`: `PRIVMSG` or `NOTICE` without text. `params`:
|
|
`[command]`.
|
|
- `421` `ERR_UNKNOWNCOMMAND`: a command the server does not know. `params`:
|
|
`[command]`.
|
|
- `432` `ERR_ERRONEUSNICKNAME`: a nick that is not allowed. `params`: `[nick]`.
|
|
- `433` `ERR_NICKNAMEINUSE`: a nick in use. `params`: `[nick]`.
|
|
- `441` `ERR_USERNOTINCHANNEL`: the nick in a `KICK`, or in a `MODE` `+o`, `-o`,
|
|
`+v` or `-v`, is not a member. `params`: `[nick, channel]`.
|
|
- `442` `ERR_NOTONCHANNEL`: the sender is not a member. `params`: `[channel]`.
|
|
- `443` `ERR_USERONCHANNEL`: an `INVITE` of a member. `params`:
|
|
`[nick, channel]`.
|
|
- `461` `ERR_NEEDMOREPARAMS`: a required field is missing. `params`:
|
|
`[command]`.
|
|
- `471` `ERR_CHANNELISFULL`, `473` `ERR_INVITEONLYCHAN`, `474`
|
|
`ERR_BANNEDFROMCHAN`, `475` `ERR_BADCHANNELKEY`: a refused `JOIN`. `params`:
|
|
`[channel]`.
|
|
- `472` `ERR_UNKNOWNMODE`: a channel mode change that is not accepted. `params`:
|
|
the change, such as `["+x"]`.
|
|
- `481` `ERR_NOPRIVILEGES`: `KILL` or `WALLOPS` by a user who is not a server
|
|
operator. No `params`.
|
|
- `482` `ERR_CHANOPRIVSNEEDED`: an action only a channel operator may take.
|
|
`params`: `[channel]`.
|
|
- `483` `ERR_CANTKILLSERVER`: a `KILL` of oneself. No `params`.
|
|
- `491` `ERR_NOOPERHOST`: a failed `OPER`. No `params`.
|
|
- `501` `ERR_UMODEUNKNOWNFLAG`: a user mode string that is not accepted. No
|
|
`params`.
|
|
- `502` `ERR_USERSDONTMATCH`: a user `MODE` for another nick. No `params`.
|
|
|
|
### Channel modes
|
|
|
|
A channel's modes are set with `MODE` (see
|
|
[`MODE`](#mode-query-or-change-modes)). A new channel has `+n` and `+t`.
|
|
|
|
- `+b <mask>`: ban. A user whose hostmask matches the mask cannot join or send.
|
|
The mask is `nick!user@host` with `*` for any run of characters and `?` for
|
|
one character; the match ignores letter case. A channel can have any number of
|
|
bans.
|
|
- `+i`: invite-only. Only a user with an invite can join. On a `+i` channel only
|
|
a channel operator can invite.
|
|
- `+k <key>`: channel key. A `JOIN` must carry the key in `body[0]`.
|
|
- `+l <number>`: member limit. A `JOIN` that would exceed it is refused.
|
|
- `+m`: moderated. Only channel operators and voiced members can send.
|
|
- `+n`: no external messages. New channels have it. The IRC listener can set and
|
|
clear it; the HTTP API cannot. It changes nothing: nobody who is not a member
|
|
can send to a channel, with or without it.
|
|
- `+s`: secret. `LIST` and `WHOIS` leave the channel out for users who are not
|
|
members. `GET /api/v1/channels` still shows it.
|
|
- `+t`: topic lock. Only a channel operator can set the topic.
|
|
- `+H <bits>`: hashcash. Every `PRIVMSG` needs a stamp of at least `bits`
|
|
leading zero bits (see [Hashcash](#hashcash)). An IRC listener client cannot
|
|
send a `PRIVMSG` to the channel; its `NOTICE` still goes through.
|
|
|
|
Two modes belong to a member rather than the channel:
|
|
|
|
- `+o`: channel operator, shown as `@` before the name in `RPL_NAMREPLY`.
|
|
- `+v`: voice, shown as `+`. A voiced member can send on a moderated channel.
|
|
|
|
### User modes
|
|
|
|
- `+o`: server operator. `OPER` sets it; `MODE <nick> -o` clears it.
|
|
- `+w`: receives `WALLOPS`. `MODE <nick> +w` sets it and `MODE <nick> -w` clears
|
|
it.
|
|
|
|
### Hostmasks
|
|
|
|
Every session has a hostmask in IRC's form, `nick!username@hostname`:
|
|
|
|
- `nick`: the session's current nick.
|
|
- `username`: the `username` sent at session creation, or the nick.
|
|
- `hostname`: the reverse DNS name of the IP address the session was created
|
|
from, or that IP address when the lookup gives no name. The lookup waits up to
|
|
three seconds.
|
|
|
|
Over the HTTP API, the server takes the client's IP address from the first entry
|
|
of the `X-Forwarded-For` header, then `X-Real-IP`, then the TCP connection (see
|
|
[Reverse proxy](#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 `<nick> text`;
|
|
- `NOTICE` as `-nick- text`;
|
|
- `JOIN` as `*** nick has joined #channel`;
|
|
- `NICK` as `*** oldnick is now known as newnick`;
|
|
- `TOPIC` as `*** nick set topic: text`;
|
|
- a numeric reply by its `body`.
|
|
|
|
## The IRC listener
|
|
|
|
The server also accepts plain IRC connections, as in RFC 1459 and RFC 2812, on
|
|
`IRC_LISTEN_ADDR` (default `:6667`). Standard IRC clients connect to it. IRC
|
|
listener users and HTTP API users share the same channels, nicks and messages:
|
|
the listener and the HTTP API use the same database and the same queues. The
|
|
listener has no TLS.
|
|
|
|
### Connecting
|
|
|
|
A line ends in LF, with or without a CR before it, and is at most 512 bytes
|
|
long, the line ending included. A longer line ends the connection. A connection
|
|
registers by sending `NICK` and `USER`. Registering creates a session with the
|
|
connection as its only client; no hashcash stamp is needed. The hostname is the
|
|
reverse DNS name of the connection's address, looked up when it connects, or the
|
|
address itself when the lookup gives no name. Before registration the server
|
|
accepts only `PASS`, `NICK`, `USER`, `PING`, `PONG`, `QUIT` and `CAP`; anything
|
|
else gets `ERR_NOTREGISTERED` (`451`).
|
|
|
|
A `PASS` before registration sets the new session's password, as the `PASS`
|
|
command does over the HTTP API. It does not log in to an existing session. A
|
|
password of the wrong length is refused with a `NOTICE` once registration
|
|
completes.
|
|
|
|
After registration the client receives `001` to `005`, the `LUSERS` replies and
|
|
the message of the day, or `ERR_NOMOTD` (`422`) when `MOTD` is empty. The `004`
|
|
modes are `mnst` and the `005` tokens `CHANTYPES=#`, `NICKLEN=32`,
|
|
`PREFIX=(ov)@+`, `CHANMODES=,,H,imnst` and `NETWORK=<server name>`.
|
|
|
|
`CAP LS` is answered with an empty capability list; other `CAP` subcommands are
|
|
ignored.
|
|
|
|
When a connection has sent nothing for 90 seconds, the server sends `PING`. When
|
|
nothing arrives in the 30 seconds after that, the server closes the connection.
|
|
Any line, such as the client's `PONG`, starts both waits again.
|
|
|
|
Closing the connection, with or without `QUIT`, ends the session, as `QUIT` does
|
|
over the HTTP API. Its channel peers see a `QUIT` with the reason
|
|
`Connection closed`; the reason given to `QUIT` is not relayed. Every command
|
|
after registration counts as activity for the idle cleanup.
|
|
|
|
### Commands
|
|
|
|
The listener accepts these commands:
|
|
|
|
- Connection: `NICK`, `USER`, `PASS`, `QUIT`, `PING`, `PONG`, `CAP`.
|
|
- Channels: `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE`.
|
|
- Messages: `PRIVMSG`, `NOTICE`.
|
|
- Information: `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY`, `USERHOST`, `VERSION`,
|
|
`ADMIN`, `INFO`, `TIME`.
|
|
- Server operator: `OPER`, `KILL`, `WALLOPS`.
|
|
|
|
They behave as over the HTTP API, with the IRC syntax and these differences:
|
|
|
|
- `JOIN` and `PART` take several channels separated by commas.
|
|
`JOIN #a,#b keya,keyb` gives each channel its key. `JOIN` adds a missing `#`;
|
|
other commands use the name as given.
|
|
- `TOPIC #channel` without a topic queries it.
|
|
- `MODE #channel <modes> [values...]` takes several letters in one string, such
|
|
as `+mt-i` or `+ov alice bob`. It accepts `i`, `m`, `n`, `s`, `t`, `o`, `v`
|
|
and `H`, but not `b`, `k` or `l`. A letter whose value is missing is skipped;
|
|
any other letter gets `ERR_UNKNOWNMODE` (`472`). The changes made go to every
|
|
member as one `MODE` line, such as `+o-v alice bob`.
|
|
- A `PRIVMSG` to a `+H` channel always gets `ERR_CANNOTSENDTOCHAN` (`404`): an
|
|
IRC line cannot carry a stamp.
|
|
- A `NOTICE` to an away user gets `RPL_AWAY` (`301`), as a `PRIVMSG` does.
|
|
- `RPL_NAMREPLY` (`353`) lists nicks, not hostmasks.
|
|
- `LIST` starts with `RPL_LISTSTART` (`321`).
|
|
- `WHOIS` sends `RPL_WHOISCHANNELS` (`319`) with `@` and `+` before the channel
|
|
names, then `RPL_WHOISIDLE` (`317`), then `RPL_AWAY` (`301`) for an away user.
|
|
It never sends `RPL_WHOISACTUALLY` (`338`). `WHOIS` with two parameters uses
|
|
the second as the nick. No parameter gets `ERR_NONICKNAMEGIVEN` (`431`).
|
|
- `WHO <nick>` answers for one user. `RPL_WHOREPLY` (`352`) flags are `H@` for a
|
|
channel operator and `H+` for a voiced member.
|
|
- `NAMES` and `WHO` without a parameter, or for an unknown channel, get only the
|
|
end reply.
|
|
- `NICK` without a nick gets `ERR_NONICKNAMEGIVEN` (`431`).
|
|
- `KICK` without a reason uses the kicked user's nick as the reason.
|
|
|
|
### What IRC clients see
|
|
|
|
Each line of a multi-line `body` reaches an IRC client as its own `PRIVMSG` or
|
|
`NOTICE` line. A CR, LF or NUL inside a line is removed first. Other users
|
|
appear with the prefix `nick!nick@*`. A mode change made over the HTTP API
|
|
arrives as an IRC `MODE` line with each value as its own parameter, such as
|
|
`MODE #general +k secretkey`. An `INVITE` arrives as
|
|
`:alice!alice@* INVITE bob #general`. Numeric replies, `KICK`, `TOPIC`, `NICK`,
|
|
`QUIT` (as `Quit: <reason>`) and `WALLOPS` arrive in their IRC forms.
|
|
|
|
The listener does not queue a client's own commands back to it. It sends the
|
|
echo itself, as IRC does, and for `PRIVMSG` and `NOTICE` sends none. Other
|
|
clients of the same session do see them. A `WALLOPS` from a client with `+w`
|
|
does come back through its queue.
|
|
|
|
An operator's `KILL` of an IRC listener user sends the user `KILL` and
|
|
`ERROR :Closing Link` and closes the connection, whichever transport the `KILL`
|
|
came from.
|
|
|
|
## The web client
|
|
|
|
The server serves a web client at `/`. It is a single page written with Preact,
|
|
built into the binary from `web/src/`. It uses the HTTP API with the browser's
|
|
cookie. On loading it calls `GET /api/v1/state?initChannelState=1` to rebuild
|
|
its channel tabs. When the server requires a hashcash stamp, it computes one
|
|
with the browser's Web Crypto API before creating a session.
|
|
|
|
## neoirc-cli
|
|
|
|
`neoirc-cli` is a terminal client for the HTTP API. The Docker build compiles it
|
|
to check that it builds and leaves it out of the image. The `Makefile` has no
|
|
target for it. Build it from the source tree:
|
|
|
|
```bash
|
|
go build -o bin/neoirc-cli ./cmd/neoirc-cli
|
|
```
|
|
|
|
It starts disconnected. Commands start with `/`:
|
|
|
|
- `/connect <url>`: create a session on the server at `<url>`, such as
|
|
`http://localhost:8080`, with the current nick. The default nick is `guest`.
|
|
The client fetches `GET /api/v1/server` first and computes a hashcash stamp
|
|
when the server needs one.
|
|
- `/nick <name>`: change the nick, or set the nick to use before connecting.
|
|
- `/join #channel`, `/part [#channel]`.
|
|
- `/msg <nick> <text>`: send a direct message. `/query <nick>`: open a window
|
|
for a user.
|
|
- `/topic <text>`: set the current channel's topic.
|
|
- `/names`: the members of the current channel. `/list`: all channels.
|
|
`/who [#channel]`, `/whois <nick>`, `/motd`.
|
|
- `/window <n>`, `/w <n>` or Alt and a digit: switch to window `n`. Window `0`
|
|
is the status window.
|
|
- `/quit`: send `QUIT` and exit. `/help`: the command list.
|
|
|
|
Text without a `/` goes as a `PRIVMSG` to the current target. `/join` and
|
|
`/query` set the target to the channel or user they open. `/window` and `/w` set
|
|
it to the channel or user of the window they switch to; a switch to window `0`
|
|
leaves it as it was. Alt and a digit switches the window shown and leaves the
|
|
target as it was. `/part` of the target's channel clears it.
|
|
|
|
The client reads its queue with `timeout=15` and retries after two seconds on an
|
|
error. It sends no hashcash stamps for `+H` channels. It sends the auth cookie
|
|
to a server on `localhost` over plain HTTP, and otherwise only over HTTPS.
|
|
|
|
## Running the server
|
|
|
|
### Configuration
|
|
|
|
Every setting is an environment variable. The server also reads a `.env` file in
|
|
its working directory, and a YAML file `neoirc.yaml` in `/etc/neoirc/` or
|
|
`$HOME/.config/neoirc/` with the same names as keys. An environment variable
|
|
wins over the file. An environment variable set to the empty string counts as
|
|
unset, except `IRC_LISTEN_ADDR`, where the empty string turns the IRC listener
|
|
off. A `neoirc.yaml` that is not valid YAML stops the server at startup.
|
|
|
|
- `PORT` (default `8080`): the HTTP port.
|
|
- `IRC_LISTEN_ADDR` (default `:6667`): the address of the IRC listener. The
|
|
empty string turns the listener off.
|
|
- `DBURL` (default
|
|
`file:///var/lib/neoirc/state.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)`):
|
|
the SQLite database. A `DBURL` of your own should keep both `_pragma`
|
|
parameters: write-ahead logging, and a wait of 5000 ms for a locked database.
|
|
It must not set `foreign_keys`; the server adds `_pragma=foreign_keys(1)`
|
|
itself and refuses to start otherwise. See [The database](#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
|
|
<https://git.eeqj.de/sneak/neoirc/issues>.
|
|
|
|
## License
|
|
|
|
MIT. See `LICENSE`.
|
|
|
|
## Author
|
|
|
|
[@sneak](https://sneak.berlin)
|