check / check (push) Waiting to run
A fresh read of README.md on next, sentence by sentence against the rules on #190 and the code, failed one sentence. The IRC listener bullet on PASS after registration ("answers a password of the wrong length with a NOTICE, not ERR_NEEDMOREPARAMS (461), and a stored password with a NOTICE too") needs a second reading to see that "a stored password" means the case where the password is accepted. It is now two short sentences: the NOTICE on success first, then the NOTICE instead of 461 on a wrong length. Both facts match handlePassPostReg and checkPasswordLength in internal/ircserver. Every other sentence passed; nothing else is changed. Model: fable-5-1
2047 lines
78 KiB
Markdown
2047 lines
78 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.
|
|
|
|
The main differences 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. A client can disconnect and come back to its
|
|
nick, its channels and the messages it missed.
|
|
|
|
## 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
|
|
chat messages to every client of the session, except the 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 HTTP
|
|
client of its session receives it too. The IRC connection that sent the
|
|
command gets it as an IRC line instead.
|
|
- **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. To a
|
|
remote host they send it only over 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:261027:neoirc::5971e"
|
|
}
|
|
```
|
|
|
|
- `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,
|
|
unless that is empty (see the `MOTD` setting under
|
|
[Configuration](#configuration)).
|
|
|
|
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`.
|
|
- `500` `internal error`.
|
|
|
|
The server checks the stamp before the nick, and spends it only when the session
|
|
is created. A request refused with `400`, `409` or `500` 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 unless that is empty. 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": 2, "name": "#dev", "topic": "" },
|
|
{ "id": 1, "name": "#general", "topic": "Welcome!" }
|
|
]
|
|
}
|
|
```
|
|
|
|
`channels` lists the channels the session has joined, sorted by name. With
|
|
`?initChannelState=1`, the server also queues, for each channel, the `JOIN`
|
|
event, the topic replies and the member list, 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. A value that is not a whole
|
|
number counts as `0`.
|
|
- `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`. A negative value,
|
|
or one that is not a whole number, counts as `0`.
|
|
|
|
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. When there are none, `messages` is an empty
|
|
array. That happens when the message was for another client of the session, or
|
|
when the time ran out. 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. A `PRIVMSG` or `NOTICE`
|
|
also gets it when `body` is not an array of strings or `meta` is not an
|
|
object.
|
|
- `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. History holds only
|
|
`PRIVMSG`; events and `NOTICE` are left out.
|
|
|
|
Without `before`, the answer holds the newest `limit` messages. With `before`,
|
|
it holds the newest `limit` messages older than that one.
|
|
|
|
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. It does not hold the direct messages the
|
|
caller sent. 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": 2, "name": "#dev", "topic": "" },
|
|
{ "id": 1, "name": "#general", "topic": "Welcome!" }
|
|
]
|
|
```
|
|
|
|
#### `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"
|
|
}
|
|
]
|
|
```
|
|
|
|
The members are sorted by nick. `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. On a command other than `PRIVMSG` and
|
|
`NOTICE`, a `body` that is not an array of strings counts as missing. 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. When the client sent none, the
|
|
message carries `{}`. 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. The server
|
|
does not add a missing `#` here: a `to` without one is a nick.
|
|
- `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 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.
|
|
|
|
The `body` names the reason in the last three cases.
|
|
|
|
- `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"}`.
|
|
|
|
A `body` or `meta` of the wrong type gets `400` `invalid request body`, and
|
|
nothing is relayed.
|
|
|
|
#### `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 does not appear 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 on every `PRIVMSG`, or stop
|
|
requiring one. `bits`, from 1 to 40, is the number of leading zero bits.
|
|
|
|
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 from the list above 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
|
|
that sets `o`, has any other letter or has no 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`, or one empty string, 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. That string holds the members'
|
|
hostmasks in nick order, separated by spaces. A `@` before a hostmask marks a
|
|
channel operator, and a `+` marks a voiced member:
|
|
|
|
```json
|
|
{
|
|
"command": "RPL_NAMREPLY",
|
|
"code": 353,
|
|
"to": "alice",
|
|
"params": ["=", "#general"],
|
|
"body": ["alice!alice@host2 +bob!bob@host3 @op1!op1@host1"]
|
|
}
|
|
```
|
|
|
|
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, in name order, 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`). When the message of the day is empty, 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.
|
|
|
|
The server sends these to every new client, on `POST /api/v1/session` and
|
|
`POST /api/v1/login`, and again on the `MOTD` command. `375` to `376` are left
|
|
out when the message of the day is empty (see the `MOTD` setting under
|
|
[Configuration](#configuration)).
|
|
|
|
- `001` `RPL_WELCOME`: `Welcome to the network, <nick>`.
|
|
- `002` `RPL_YOURHOST`: `Your host is <server name>, running version <version>`.
|
|
`<version>` is `dev` in a binary built without one.
|
|
- `003` `RPL_CREATED`: `This server was created <date the server started>`.
|
|
- `004` `RPL_MYINFO`: `params` `[server name, version, "ow", "Hbiklmnostv"]`.
|
|
- `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. Each line names the command that gets the reply, then what
|
|
`params` holds:
|
|
|
|
- `221` `RPL_UMODEIS`: user `MODE`. No `params`; the modes are in `body`.
|
|
- `256` `RPL_ADMINME`: `ADMIN`. `[server name]`.
|
|
- `257` `RPL_ADMINLOC1`, `258` `RPL_ADMINLOC2`, `259` `RPL_ADMINEMAIL`: `ADMIN`.
|
|
No `params`.
|
|
- `301` `RPL_AWAY`: a `PRIVMSG` to an away user. `[nick]`; the away message is
|
|
in `body`.
|
|
- `302` `RPL_USERHOST`: `USERHOST`. No `params`.
|
|
- `305` `RPL_UNAWAY`, `306` `RPL_NOWAWAY`: `AWAY`. No `params`.
|
|
- `311` `RPL_WHOISUSER`: `WHOIS`. `[nick, username, hostname, "*"]`.
|
|
- `312` `RPL_WHOISSERVER`: `WHOIS`. `[nick, server name]`.
|
|
- `313` `RPL_WHOISOPERATOR`: `WHOIS`. `[nick]`.
|
|
- `317` `RPL_WHOISIDLE`: `WHOIS`. `[nick, idle seconds, signon Unix time]`.
|
|
- `318` `RPL_ENDOFWHOIS`: `WHOIS`. `[nick]`.
|
|
- `319` `RPL_WHOISCHANNELS`: `WHOIS`. `[nick]`; the channels are in `body`.
|
|
- `338` `RPL_WHOISACTUALLY`: `WHOIS`. `[nick, IP address]`.
|
|
- `315` `RPL_ENDOFWHO`: `WHO`. `[channel]`.
|
|
- `352` `RPL_WHOREPLY`: `WHO`.
|
|
`[channel, username, hostname, server name, nick, "H"]`.
|
|
- `322` `RPL_LIST`: `LIST`. `[channel, member count]`; the topic is in `body`.
|
|
- `323` `RPL_LISTEND`: `LIST`. No `params`.
|
|
- `324` `RPL_CHANNELMODEIS`: a channel `MODE` query, and a `+H` or `-H` change.
|
|
`[channel, mode string]`.
|
|
- `329` `RPL_CREATIONTIME`: a channel `MODE` query. `[channel, Unix time]`.
|
|
- `331` `RPL_NOTOPIC`: `JOIN` and the channel state after login or
|
|
`?initChannelState=1`. `[channel]`.
|
|
- `332` `RPL_TOPIC`: `JOIN`, `TOPIC` and the channel state. `[channel]`; the
|
|
topic is in `body`.
|
|
- `333` `RPL_TOPICWHOTIME`: `JOIN`, `TOPIC` and the channel state.
|
|
`[channel, nick who set the topic, Unix time]`.
|
|
- `341` `RPL_INVITING`: `INVITE`. `[nick, channel]`.
|
|
- `351` `RPL_VERSION`: `VERSION`. `["neoirc-<version>.", server name]`.
|
|
- `353` `RPL_NAMREPLY`: `JOIN`, `NAMES` and the channel state. `["=", channel]`;
|
|
the members are in `body`.
|
|
- `366` `RPL_ENDOFNAMES`: `JOIN`, `NAMES` and the channel state. `[channel]`.
|
|
- `367` `RPL_BANLIST`: `MODE #channel +b`.
|
|
`[channel, mask, nick who set it, Unix time]`.
|
|
- `368` `RPL_ENDOFBANLIST`: `MODE #channel +b`. `[channel]`.
|
|
- `371` `RPL_INFO`, `374` `RPL_ENDOFINFO`: `INFO`. No `params`.
|
|
- `381` `RPL_YOUREOPER`: `OPER`. No `params`.
|
|
- `391` `RPL_TIME`: `TIME`. `[server name]`; the time is in `body`.
|
|
|
|
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`) over the HTTP API,
|
|
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. `1:20:261027:neoirc::5971e` is a real 20-bit stamp
|
|
for 27 October 2026; its hash starts with `00000e`. 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.
|
|
|
|
The server keeps spent stamps in memory until their date is 48 hours old; 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.
|
|
|
|
This message carries a real 20-bit stamp for 27 October 2026, for the `body`
|
|
spelled exactly `["hello world"]`:
|
|
|
|
```json
|
|
{
|
|
"command": "PRIVMSG",
|
|
"to": "#general",
|
|
"body": ["hello world"],
|
|
"meta": {
|
|
"hashcash": "1:20:261027:#general:d7f54e4fb4d97b407a45ce7ba369ec4cb70d15337fb33a1adfcb4f523aeb1b32:16ec2f"
|
|
}
|
|
}
|
|
```
|
|
|
|
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 at least 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 the first entry of `X-Forwarded-For`, else from
|
|
`X-Real-IP`, else from the TCP connection. Without a reverse proxy that
|
|
overwrites 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
|
|
except requests.exceptions.Timeout:
|
|
continue
|
|
except requests.exceptions.RequestException as exc:
|
|
print(f"error: {exc}")
|
|
time.sleep(2)
|
|
continue
|
|
if resp.status_code == 401:
|
|
break # the session is gone
|
|
if resp.status_code != 200:
|
|
time.sleep(2)
|
|
continue
|
|
data = resp.json()
|
|
last_id = data["last_id"]
|
|
for msg in data["messages"]:
|
|
print(f"[{msg['command']}] <{msg.get('from', '')}> "
|
|
f"{' '.join(msg['body'])}")
|
|
```
|
|
|
|
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
|
|
if (resp.status !== 200) {
|
|
await new Promise((r) => setTimeout(r, 2000));
|
|
continue;
|
|
}
|
|
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));
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Both loops keep `after` where it was unless the answer is `200`, and stop on
|
|
`401`.
|
|
|
|
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. A `PRIVMSG` or `NOTICE`
|
|
can instead be dropped by the `id` its `POST` response returned; the response
|
|
to an event carries no `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 that is empty. The `004`
|
|
user modes are `ow` and its channel modes are `Himnostv`. The `005` tokens are
|
|
`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 ends the session, as `QUIT` does over the HTTP API. After
|
|
a `QUIT`, channel peers see the reason the client gave, or `Client quit` when it
|
|
gave none. Without a `QUIT`, they see `Connection closed`. 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.
|
|
- `MOTD` sends only `375` to `376`, or `ERR_NOMOTD` (`422`) when the message of
|
|
the day is empty, without the welcome and `LUSERS` replies.
|
|
- `PASS` after registration answers with a `NOTICE` when it stores the password.
|
|
A password of the wrong length also gets a `NOTICE`, not `ERR_NEEDMOREPARAMS`
|
|
(`461`).
|
|
|
|
### 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, `JOIN`, `PART`, `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`, `/w` and
|
|
Alt with a digit set it to the channel or user of the window they switch to. A
|
|
switch to window `0` clears it, and so does `/part` of the target's channel.
|
|
|
|
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
|
|
over plain HTTP to a server on `localhost` or a loopback address such as
|
|
`127.0.0.1`, 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. A variable from the
|
|
environment wins over one in `.env`, and both win over `neoirc.yaml`. 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. A setting that
|
|
does not parse or is out of range also stops the server at startup, and the
|
|
error names the setting.
|
|
|
|
- `PORT` (default `8080`): the HTTP port. `0` lets the system pick a free 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. An empty environment variable counts as unset and gives
|
|
the banner. To send no message of the day, put `MOTD: ""` in `neoirc.yaml`.
|
|
- `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`. It
|
|
must be at least `1`.
|
|
- `LOGIN_RATE_LIMIT` (default `1`): login attempts allowed per second per IP
|
|
address. A fraction such as `0.5` is allowed. It must be above `0`.
|
|
- `LOGIN_RATE_BURST` (default `5`): how many attempts an address may make at
|
|
once before the rate applies. It must be at least `1`.
|
|
- `SESSION_IDLE_TIMEOUT` (default `720h`, 30 days): a duration with a unit, `h`
|
|
for hours, `m` for minutes or `s` for seconds, such as `720h` or `36h30m`. A
|
|
client with no request for this long is removed, and a session left with no
|
|
client ends. See [Data lifecycle](#data-lifecycle). It must be above zero.
|
|
- `MESSAGE_MAX_AGE` (default `720h`): messages older than this duration are
|
|
deleted. It must be above zero.
|
|
- `QUEUE_MAX_AGE` (default `720h`): queue entries older than this duration are
|
|
deleted. It must be above zero.
|
|
- `DEBUG` (default `false`): `true` turns on debug logging.
|
|
- `SENTRY_DSN` (default empty): when set, panics in HTTP requests are reported
|
|
to Sentry. A value that is not a valid DSN stops the server at startup.
|
|
- `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 build runs the tests and
|
|
the linter first and stops when either fails. 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` to the client's address, replacing any value the client
|
|
sent. The server reads that header first and trusts it from anyone; it reads
|
|
`X-Real-IP` only when `X-Forwarded-For` is absent. Without a proxy that
|
|
overwrites the header, 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 still running after 60 seconds is cancelled, and the
|
|
connection closes without a response. 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, protocol, status, latency, remote
|
|
address, user agent, referrer 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 its first start it runs `internal/db/schema/001_initial.sql`, which
|
|
creates the tables, and records that 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 `make`, `git`, Go, Node and `yarn`, and
|
|
`prettier` through `yarn`. Docker must already be installed.
|
|
- `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`. With the default `DBURL`, both stop at startup when
|
|
`/var/lib/neoirc/` does not exist or is not writable. Set `DBURL` to a
|
|
writable path to avoid that.
|
|
- `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)
|