Author SHA1 Message Date
clawbot 4c397786e6 Rewrite README.md sentence by sentence (closes #190)
check / check (push) Waiting to run
README.md is rewritten from scratch against the 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, puts every endpoint, command,
numeric reply, mode and setting in reference sections, and ends with
development and testing. Where the old text disagreed with the code,
the code decides; the PR body lists each case. The hashcash examples
are real 20-bit stamps. REPO_POLICIES.md and CONVENTIONS.md are copies
of shared documents and are untouched.

Model: fable-5-1
2026-10-27 20:50:30 +00:00
+215 -146
View File
@@ -25,7 +25,7 @@ neoirc is IRC with a different transport. The commands are IRC's: `PRIVMSG`,
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:
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.
@@ -48,9 +48,8 @@ The repository ships three programs:
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.
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
@@ -123,12 +122,14 @@ 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.
- **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
client of its session receives it too.
`JOIN`, `PART`, `NICK`, `TOPIC`, `MODE` or `KICK` causes an event, every HTTP
client of its session receives it too; an IRC connection that sent the command
gets the IRC echo 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.
@@ -185,8 +186,8 @@ 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.
server on `localhost` over plain HTTP too; Python's `requests` does not. To any
other 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`.
@@ -201,7 +202,7 @@ No auth. Request body:
{
"nick": "alice",
"username": "alice",
"pow_token": "1:20:260310:neoirc::3a2f1"
"pow_token": "1:20:261027:neoirc::5971e"
}
```
@@ -221,8 +222,9 @@ Response `201`, with the cookie:
`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.
[Numeric replies](#numeric-replies)). It also holds the message of the day,
unless that is empty (see the `MOTD` setting under
[Configuration](#configuration)).
Errors:
@@ -233,9 +235,11 @@ Errors:
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` or `409` leaves the stamp unspent.
is created. A request refused with `400`, `409` or `500` leaves the stamp
unspent.
#### `POST /api/v1/login`: add a client to a session
@@ -253,7 +257,7 @@ Response `200`, with a new cookie for the new client:
```
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
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`;
@@ -280,16 +284,16 @@ Needs auth. Response `200`:
"id": 1,
"nick": "alice",
"channels": [
{ "id": 1, "name": "#general", "topic": "Welcome!" },
{ "id": 2, "name": "#dev", "topic": "" }
{ "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 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.
`?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.
@@ -298,9 +302,11 @@ its channel tabs.
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.
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`.
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`:
@@ -327,9 +333,9 @@ 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.
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
@@ -365,14 +371,15 @@ Needs auth. Query parameters:
- `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.
Response `200`: an array of `PRIVMSG` messages, oldest first. History holds only
`PRIVMSG`; events and `NOTICE` are left out.
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.
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.
@@ -399,8 +406,8 @@ Needs auth. Response `200`: every channel on the server, sorted by name, secret
```json
[
{ "id": 1, "name": "#general", "topic": "Welcome!" },
{ "id": 2, "name": "#dev", "topic": "" }
{ "id": 2, "name": "#dev", "topic": "" },
{ "id": 1, "name": "#general", "topic": "Welcome!" }
]
```
@@ -422,8 +429,9 @@ Needs auth. `{name}` is the channel name without the `#`. Response `200`:
]
```
`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`.
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
@@ -522,16 +530,17 @@ with these fields:
- `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.
line. A `body` is never a bare string; a client's `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, 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`.
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
@@ -555,7 +564,8 @@ otherwise, an error numeric comes with the HTTP response `{"status": "error"}`.
{"command": "PRIVMSG", "to": "bob", "body": ["hey"]}
```
- `to` (required): a channel name, or a nick for a direct message.
- `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`.
@@ -602,7 +612,7 @@ 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
- 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
@@ -826,8 +836,8 @@ changes:
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.
- `+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`
@@ -850,8 +860,8 @@ 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_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;
@@ -864,9 +874,9 @@ 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.
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"] }
@@ -949,9 +959,9 @@ HTTP response: `{"status": "ok"}`.
```
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 `-`.
`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"}`.
@@ -963,9 +973,9 @@ HTTP response: `{"status": "ok"}`.
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:
`["=", channel]` and a `body` with one string: the members' hostmasks in nick
order, separated by spaces, each with `@` before a channel operator and `+`
before a voiced member:
```json
{
@@ -973,7 +983,7 @@ member:
"code": 353,
"to": "alice",
"params": ["=", "#general"],
"body": ["@op1!op1@host1 alice!alice@host2 +bob!bob@host3"]
"body": ["alice!alice@host2 +bob!bob@host3 @op1!op1@host1"]
}
```
@@ -990,7 +1000,7 @@ HTTP response: `{"status": "ok"}`.
{ "command": "LIST" }
```
The reply is one `RPL_LIST` (`322`) per channel, with `params`
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.
@@ -1058,8 +1068,8 @@ HTTP response: `{"status": "ok"}`.
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.
per line, `RPL_ENDOFMOTD` (`376`). When the message of the day is empty, the
welcome and `LUSERS` replies come alone.
HTTP response: `{"status": "ok"}`.
@@ -1168,11 +1178,14 @@ 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:
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, "", "ikmnostl"]`.
- `005` `RPL_BOUNCE`: `params`
@@ -1189,32 +1202,49 @@ Sent to every new client, on `POST /api/v1/session`, `POST /api/v1/login` and
- `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:
Answers to commands. Each line names the command that gets the reply, then what
`params` holds:
- `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`.
- `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:
@@ -1305,9 +1335,9 @@ of the `X-Forwarded-For` header, then `X-Real-IP`, then the TCP connection (see
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.
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
@@ -1336,7 +1366,8 @@ has six fields separated by colons:
- `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:
`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
@@ -1358,9 +1389,9 @@ The server accepts a stamp when all of these hold:
- 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.
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
@@ -1378,22 +1409,26 @@ The stamp is bound to the channel and to the message:
The version, `bits` and date rules of session stamps apply to channel stamps
too, with the channel's `+H` value as the difficulty.
Example:
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:260317:#general:a1b2c3...:1f4a" }
"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 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.
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
@@ -1404,9 +1439,10 @@ over the limit gets `429` `too many login attempts, try again later` with
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)).
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
@@ -1440,16 +1476,22 @@ while True:
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:
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:
@@ -1464,6 +1506,10 @@ async function pollLoop() {
{ 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);
@@ -1474,6 +1520,9 @@ async function pollLoop() {
}
```
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
@@ -1481,7 +1530,9 @@ Advice for client authors:
- 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`.
`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
@@ -1519,7 +1570,7 @@ 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`
the message of the day, or `ERR_NOMOTD` (`422`) when that is empty. The `004`
modes are `mnst` and the `005` tokens `CHANTYPES=#`, `NICKLEN=32`,
`PREFIX=(ov)@+`, `CHANMODES=,,H,imnst` and `NETWORK=<server name>`.
@@ -1572,6 +1623,11 @@ They behave as over the HTTP API, with the IRC syntax and these differences:
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 a password of the wrong length with a
`NOTICE`, not `ERR_NEEDMOREPARAMS` (`461`), and a stored password with a
`NOTICE` too.
### What IRC clients see
@@ -1580,8 +1636,9 @@ Each line of a multi-line `body` reaches an IRC client as its own `PRIVMSG` or
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.
`: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
@@ -1635,7 +1692,8 @@ 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.
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
@@ -1643,12 +1701,14 @@ to a server on `localhost` over plain HTTP, and otherwise only over HTTPS.
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.
`$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.
- `PORT` (default `8080`): the HTTP port.
- `PORT` (default `8080`): the HTTP port. A value that is not a whole number
counts as `0`, and the system then picks a free port.
- `IRC_LISTEN_ADDR` (default `:6667`): the address of the IRC listener. The
empty string turns the listener off.
- `DBURL` (default
@@ -1662,27 +1722,34 @@ off. A `neoirc.yaml` that is not valid YAML stops the server at startup.
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.
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.
`POST /api/v1/session` needs. `0` turns the requirement off, and so does a
value that is not a whole number.
- `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.
`POST /api/v1/session`, `POST /api/v1/login` and `POST /api/v1/messages`. A
value of `0` or less, or one that is not a whole number, means the default.
- `LOGIN_RATE_LIMIT` (default `1`): login attempts allowed per second per IP
address. A fraction such as `0.5` is allowed. A value of `0` or less, or one
that is not a number, means the default.
- `LOGIN_RATE_BURST` (default `5`): how many attempts an address may make at
once before the rate applies. A value of `0` or less, or one that is not a
whole number, means the default.
- `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). A value that is not a
duration is logged, and the default applies.
- `MESSAGE_MAX_AGE` (default `720h`): messages older than this duration 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 duration 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.
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.
@@ -1702,7 +1769,8 @@ NEOIRC_HASHCASH_BITS=20
### Docker
`make docker` builds the image, tagged `neoirc`. The image:
`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`;
@@ -1728,10 +1796,11 @@ 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.
- 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.
@@ -1768,8 +1837,8 @@ 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.
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:
@@ -1784,16 +1853,16 @@ The IRC listener has no TLS.
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.
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 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`.
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:
@@ -1893,8 +1962,8 @@ 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/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