Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c397786e6 |
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user