37 Commits
Author SHA1 Message Date
sneak d6a6296c1e deploy (#189)
check / check (push) Canceled after 0s
Reviewed-on: #189
2026-10-22 18:52:00 +02:00
sneak 12a232ee1b next → main: utility commands, IRC listener and audit fixes, build fixes (#111)
check / check (push) Canceled after 0s
Reviewed-on: #111
2026-10-22 18:27:05 +02:00
clawbot d31fd529e2 Make an IRC listener connection follow its session's nick (closes #184)
check / check (push) Canceled after 0s
An IRC listener connection kept its own copy of its nick, so after the session's nick was changed from another client it went on sending under the old nick, which another user could then take. Now, whenever the relay passes on a NICK line and after the connection's own NICK is stored, the connection takes the nick its session holds in the database, read by session ID under the connection's lock. Taking the nick from the database rather than from the line means a connection can never follow another user's rename and send as them. Handlers read the nick under the same lock.

Untested: an HTTP API rename landing while the connection's own NICK is being stored; that order cannot be produced reliably.

Model: opus-5-5
2026-10-21 17:56:02 +02:00
clawbot 129d87d578 Show an IRC listener user what their session sends from other clients (closes #179)
check / check (push) Canceled after 0s
The IRC listener's relay dropped every line sent under the connection's own nick, so a user on the web client and an IRC client at once never saw on IRC what they did from the web client. The relay now drops nothing by nick. Instead the fan-out skips only the sending client's own queue: the IRC listener passes its own client, whose handler already wrote the echo, and the HTTP API skips none. Channel messages and PARTs now reach the sender's other clients, so an HTTP client gets its own PART event; neoirc-cli ignores it, as it already shows its own part. README.md names the commands covered.

Judgement call: a PRIVMSG or NOTICE sent on an IRC connection is not echoed back to it, as in IRC.

Model: opus-5-5
2026-10-21 15:23:13 +02:00
clawbot d539b4c558 Make TestIdleCleanupKeepsActiveUser independent of timing (closes #181)
check / check (push) Canceled after 0s
The test slept 500 ms, had one user send PING, and then asked for sessions last seen before the current time minus 500 ms. On a loaded host the PONG could arrive more than 500 ms later, so the active user counted as idle and the build went red. The code was right: activity is recorded before the reply and the stored time keeps nanoseconds. The test now reads the clock once after both users register and before the PING, and uses that as the cutoff, so the result follows from the order of events.

Judgement call: the test relies on the wall clock not stepping backwards between the cutoff and the PING.

Model: opus-5-5
2026-10-21 13:06:01 +02:00
clawbot 3cbd7554e2 Relay INVITE to IRC listener users as an INVITE line (closes #147)
check / check (push) Canceled after 0s
An invite for an IRC listener user arrived as a server NOTICE that did
not name the inviter, so no IRC client treated it as an invite. The
relay now sends it as an INVITE line from the inviter, with the channel
taken from the message body, and sends nothing when the body names no
channel. The IRC listener now queues the INVITE with the channel as its
body, as the HTTP API already did, so an invite from either transport
has the same shape. The README's IRC listener section says how an
invite arrives.

Model: opus-5-5
2026-10-21 07:31:38 +02:00
clawbot ddfbdc1277 Keep the web client's saved channels and show who changed a mode (closes #176)
check / check (push) Canceled after 0s
The web client saved its channel tabs to localStorage whenever the
tabs changed, including on page load, when only the Server tab
exists. That wrote an empty list over the saved one before login
read it, so a new session never rejoined anything. The save now
waits until the user is logged in, and login reads the saved list
before it marks the user logged in.

A MODE message from the server fell through to the Server tab as its
bare body. It now shows in the channel's tab as "alice sets mode
+v bob on #chan".

Model: opus-5-5
2026-10-21 06:31:29 +02:00
clawbot 3cbdac7bda Send an IRC listener user their own TOPIC, MODE and KICK once (closes #143)
check / check (push) Canceled after 0s
The service sends a TOPIC, MODE or KICK to every channel member, the
issuer included, and the IRC listener also echoes it to the issuer's
connection, so an IRC listener user got each of their own changes
twice. The relay now drops one whose sender is the connection's own
nick, as it already does for PRIVMSG, NOTICE, JOIN, PART, NICK and
QUIT. The direct echo stays: it carries the real hostmask and arrives
in order. The README MODE section no longer describes a second copy.

Model: opus-5-5
2026-10-21 06:14:42 +02:00
clawbot d37c130ee8 Bring the web client in line with the current API (closes #145)
check / check (push) Canceled after 0s
The web client was written against an older shape of the API.

MODE now carries its change in `body`, so /mode sets modes; /mode
alone queries them and the 324 reply is shown. Numeric replies are
matched on `code`, so /list, /who and /whois print their lines, and
353 names are read as hostmasks. The member list takes @ and + from
`isOperator` and `isVoiced`. A 401 on the poll reloads the page, which
shows the login screen, where it used to retry for ever. The token
code is gone: no Authorization header, nothing stored, and the page
always asks /state whether it can resume a session.

Model: opus-5-5
2026-10-21 05:48:33 +02:00
clawbot 24b9b58cac Register the broker wait before the first poll (closes #157)
check / check (push) Canceled after 0s
GET /api/v1/messages read the client output queue and only then
registered with the broker. Notify wakes only the channels registered
at that moment, so a message queued between the empty read and the
registration waited out the whole long-poll timeout. The handler now
registers first and removes its channel on every exit path; the IRC
relay loop registers before each drain in the same way.

The handlers read the queue through a function field that defaults to
Database.PollMessages, so tests can act just after a read: one checks
that a message queued there comes back at once, others that no wait
channel stays registered however a request ends.

Model: opus-5-5
2026-10-21 05:31:24 +02:00
clawbot c267cd215c Refuse passwords over 72 bytes and confirm only stored ones (closes #144)
check / check (push) Canceled after 0s
bcrypt cannot hash a password longer than 72 bytes. PASS over the HTTP
API answered one with a 500, and the IRC listener confirmed it without
storing it.

Both transports now refuse a password outside 8 to 72 bytes with the
reply a short one already got: 461 over the HTTP API, a NOTICE over the
IRC listener. setPassword returns its error, and "Password set" is sent
only when the password was stored. A PASS given before registration, an
empty one included, is checked after the welcome burst and gets the
same NOTICE.

Model: opus-5-5
2026-10-21 05:02:00 +02:00
clawbot 0e6d06a7fc Spend a hashcash stamp only when the request succeeds (closes #158)
check / check (push) Canceled after 0s
A session request refused with 400 or 409, or a +H channel message
refused for membership, a ban or +m, used to spend its stamp, so a
retry with the same stamp was refused as already used.

hashcash.Validator.Validate is split into Check, which spends
nothing, and Claim, which records the stamp; Release undoes a claim.
Session creation checks the stamp first, claims it just before
CreateSession and releases it if CreateSession fails.
SendChannelMessage checks the stamp after the membership, ban and +m
checks.

Model: opus-5-5
2026-10-21 04:02:06 +02:00
clawbot e7e408c93e Resolve IRC client hostnames off the accept loop (closes #156)
check / check (push) Canceled after 0s
acceptLoop called newConn, which did the reverse DNS lookup, with its
three-second timeout, before the loop could accept the next
connection, so one client whose address had no answering reverse DNS
held up every other IRC connect. serve now does the lookup at its
start, on the connection's own goroutine, and the accept loop only
accepts.

The server keeps the lookup function in a field, as it does the ping
times, so that a test can give it one that blocks. The new test
checks that a second client registers while the first one's lookup
has not returned.

Model: opus-5-5
2026-10-21 03:31:27 +02:00
clawbot 3822cdaf1d Decode the API's actual fields in neoirc-cli (closes #146)
check / check (push) Canceled after 0s
The CLI read the poll cursor from `lastId` while the server sends
`last_id`, so the cursor stayed at 0 and every poll returned the
whole queue at once. It now reads `last_id`.

/names decodes the member objects the server sends and prints each
nick with `@` for an operator or `+` for a voiced member. /list
prints each channel's name and topic, since the server sends no
member count. StateResponse.Channels decodes channel objects.

/topic with no text prints a usage line and sends nothing, because
the HTTP API has no topic query and a TOPIC without text gets 461.
The help says "Set topic".

The client tests serve the bodies the handlers send, so a wrong JSON
tag in types.go fails a test; another test covers the /names prefixes.

Model: opus-5-5
2026-10-21 02:48:31 +02:00
clawbot b879644e41 Broadcast a session's QUIT once when its clients leave together (closes #154)
check / check (push) Canceled after 0s
Logout deleted the client, counted the session's clients and ran
BroadcastQuit as separate statements, and BroadcastQuit read the peers,
parted, deleted empty channels and deleted the session separately too.
Two logouts or QUITs of one session at once both sent its QUIT, and a
JOIN landing between a part and the channel's deletion got into a +k
channel without the key.

db.QuitSession now does all of that in one transaction, parting each
channel as PartChannelAndDeleteIfEmpty does, and stores and queues the
QUIT in it only when it deleted the session, so the QUIT is queued
before the nick is free. db.LogoutClient deletes the client and counts
the rest in the same transaction.

Model: opus-5-5
2026-10-21 01:48:26 +02:00
clawbot bbdf3a70af Fail startup when the HTTP listener cannot bind (closes #167)
check / check (push) Canceled after 0s
The HTTP server ran ListenAndServe in a goroutine, so a failed bind
was only logged and neoircd kept running without its HTTP API.

The start hook now binds the listen address and returns the bind
error, so neoircd exits 1 with an error naming the address. If serving
the bound listener later ends with anything other than a normal
shutdown, the server stops the app through fx.Shutdowner with exit
code 1; the handler tests now pass it that Shutdowner.

Tests check that a taken port fails startup, that a serve failure
stops the app with exit code 1, and that a normal stop does not. The
server tests no longer run in parallel: building the app writes the
global viper instance.

Model: opus-5-5
2026-10-21 01:14:24 +02:00
clawbot 497514d3df Wait for IRC listener cleanup and HTTP shutdown on stop (closes #159)
check / check (push) Canceled after 0s
Stopping the server returned before the IRC listener's connections had
run their cleanup, and the database closed under them, so their
sessions, clients and channel memberships stayed and their nicks stayed
taken after a restart.

The IRC listener's stop now waits up to five seconds for every
connection's cleanup, which now runs on a context that stopping does
not cancel. The HTTP server's stop hook now runs Shutdown itself and
waits for it; httpServer is set before serving starts, and the server's
own signal watcher, which raced fx's stop hooks, is gone.

Model: opus-5-5
2026-10-20 23:48:30 +02:00
clawbot 108e381428 Run JOIN, PART and KICK as one transaction each (closes #153)
check / check (push) Canceled after 0s
JoinChannel made the channel, counted its members, ran the join
checks and inserted the member as separate statements, so two
requests could each decide on a count the other was about to change:
+l was exceeded, two first joiners both became operator, and a JOIN
racing the last PART failed its foreign key and answered 500.

db.CreateOrJoinChannel now does all of that, and uses up the invite,
in one transaction; the join checks moved into it, and the service
turns its refusals into the same IRC errors as before. PART and KICK
remove the member and delete an empty channel in one transaction
(db.PartChannelAndDeleteIfEmpty). The db functions this leaves with
no caller are removed.

Model: opus-5-5
2026-10-20 22:48:25 +02:00
clawbot 292f1eb7a9 Serve the index page for an empty URL path instead of panicking (closes #149)
check / check (push) Canceled after 0s
setupSPA took the file name from request.URL.Path[1:], which panics
when the path is empty, as it is for an absolute-form request line
with no path ("GET http://host HTTP/1.1"). It now trims a leading "/"
instead; an empty name fails to read, so the request gets the index
page, as "/" already does.

The new test in internal/server sends such a request and expects 200
and the index page. export_test.go gives it a Server whose router has
only the web client route.

Model: opus-5-5
2026-10-20 21:31:26 +02:00
clawbot e3d01454b3 PING a quiet IRC listener client before closing it (closes #155)
check / check (push) Canceled after 0s
serve closed a connection after five minutes with nothing read and never
sent a PING, so a quiet registered client lost its session. It now sends
PING :<server> after pingInterval with nothing read, and closes the
connection when nothing is read within pongDeadline after that. Every
line read starts both waits again. readTimeout is removed.

Unregistered connections are sent the PING too, so a PONG before
registration is now ignored instead of answered with ERR_NOTREGISTERED.

Model: opus-5-5
2026-10-20 20:53:00 +02:00
clawbot e489f3f3d1 Spend a channel hashcash stamp in one statement (closes #150)
check / check (push) Canceled after 0s
checkChannelHashcash looked a stamp up with one statement and recorded
it with another, so concurrent requests carrying the same stamp could
all pass the lookup before any of them recorded it, and each message
was sent. RecordSpentHashcash now reports whether its INSERT OR IGNORE
added a row, and the message is refused when it did not. A single
statement is atomic in SQLite. IsHashcashSpent had no other caller and
is removed. A failed write of the stamp now refuses the message instead
of being logged and ignored.

Model: opus-5-5
2026-10-20 20:31:27 +02:00
clawbot 164fc93a48 Bind history to the recipient session and channel, not the name (closes #148)
check / check (push) Canceled after 0s
History was selected by msg_to, so whoever next held a released nick, or
created a deleted channel again under its old name, could read the
earlier holder's messages. Each message row now records the session a
DM or INVITE was sent to (to_session_id) and the channel a channel
message was sent to (to_channel_id), and GET /api/v1/history selects by
the one that applies, using its index. A session's DM history now also
follows it across nick changes. A DM or channel message whose recipient
is deleted before it is stored gets the unknown nick or channel error.

Model: opus-5-5
2026-10-20 19:48:35 +02:00
clawbot 5100b9a665 Remove the relay's broker wait channel after each tick (closes #151)
check / check (push) Canceled after 0s
The IRC relay registers a broker wait channel on each pass of its
loop, and when the 100 ms ticker fired instead of a notification it
looped without removing that channel. An idle IRC listener connection
added ten channels a second to the broker, and all but the last stayed
after the connection closed. The ticker case now removes its channel,
as the HTTP long-poll does after its timer.

Broker.ListenerCount returns how many wait channels a user holds, so
the test can check that an idle connection holds at most one, and
none after it closes.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-20 18:02:03 +02:00
clawbot 0b09cf838c Check nicks on the IRC listener with the HTTP API's rule (closes #142)
check / check (push) Canceled after 0s
The nick rule moves from internal/handlers to internal/service as
ValidNick, and the HTTP API and both IRC listener NICK paths call it.
The IRC listener used to cut a nick to 32 bytes and accept anything
else, so a nick with spaces such as "bob PRIVMSG #inj :hi" made the
lines built from it read as another user's PRIVMSG. It now answers a
nick the rule refuses with ERR_ERRONEUSNICKNAME (432), before and
after registration, and keeps the nick as it was.

Model: opus-5-5
2026-10-20 17:31:31 +02:00
clawbot eceb5f2b2c Compare nicks by exact case everywhere (closes #128)
check / check (push) Canceled after 0s
README.md rules that nicks are case-sensitive, but the IRC listener's
relay and user MODE on both transports compared nicks ignoring case:
an IRC listener user alice never saw a PRIVMSG, NOTICE, JOIN, PART,
NICK or QUIT from Alice, and MODE Alice from alice was taken as her
own nick. These checks now use ==.

Tests cover alice receiving each of these from Alice, MODE Alice from
alice getting 502 on both transports, and Alice registering while
alice exists. README.md drops the text that described the old
behaviour.

Model: opus-5-5
2026-10-20 16:31:28 +02:00
clawbot 3c725a9ca8 Enforce channel keys, invites, +s and +H on the IRC listener (closes #122)
check / check (push) Canceled after 0s
The IRC listener's JOIN now passes the channel key on, INVITE records
the invite, LIST and WHOIS hide +s channels from non-members with the
queries the HTTP API uses, and PRIVMSG to a +H channel gets 404.

The INVITE checks and the channel hashcash check move from the HTTP
handlers into the service, so both transports run the same code. An IRC
client cannot attach a stamp, so its PRIVMSG to a +H channel always
fails that check. The unfiltered channel list query, now unused, is
removed.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-20 16:02:14 +02:00
clawbot 1308769a25 Fix IRC listener relay of bodies and modes, and JOIN names (closes #127)
check / check (push) Canceled after 0s
The IRC listener relayed only the first line of a PRIVMSG or NOTICE
body; it now sends one line per body line. A relayed MODE change now
carries its key, limit or mask, each value its own parameter, also in
the echo to the user who made it. CR, LF and NUL are removed from each
parameter and from each whole line sent to an IRC listener connection,
so no stored value can start a line of its own. The channel name check
moved into service.JoinChannel, so the IRC listener's JOIN refuses the
same names with 403.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-20 13:48:16 +02:00
clawbot 4ab320ab2d Give each handler and service test its own database (closes #131)
check / check (push) Canceled after 0s
Test databases in internal/handlers and internal/service were named
after the address of the running test. A later test could get the
address of a finished one, and with it a database that test's server
might still hold open. Each test database now gets a random name, as
in internal/ircserver.

A new test in each package opens two test databases at once and checks
that they do not share one; both new tests fail with the old names.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-20 13:04:40 +02:00
clawbot 5b430cf835 Let an empty IRC_LISTEN_ADDR disable the IRC listener (closes #124)
check / check (push) Canceled after 0s
Viper treats an environment variable set to the empty string as unset,
so an empty IRC_LISTEN_ADDR fell back to the :6667 default and the IRC
listener could not be disabled from the environment. The config now
reads IRC_LISTEN_ADDR with os.LookupEnv as well, so an empty value
disables the listener and an unset one keeps :6667. Viper's own switch
for this applies to every setting, so it is not used. New config tests
cover both cases, and README.md says how to disable the listener.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-20 12:35:18 +02:00
clawbot 1019419b17 Page history by message id (closes #125)
check / check (push) Canceled after 0s
GET /api/v1/history took a database id for before, but history
responses carry only each message's id, so a client had nothing to
pass for the next page. before now takes that id: the server finds
the message among those sent to the target and returns the page
older than it. An id with no such message gets 400 "unknown before
id" instead of a silent full page. A handler test pages back through
a channel using only ids from earlier responses, and README.md's
history section describes the new parameter.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-20 11:58:39 +02:00
clawbot 7c6f0bc01e Run SQLite in WAL mode with a busy timeout the driver reads (closes #123)
check / check (push) Canceled after 0s
The default database URL asked for WAL mode with `_journal_mode` and
`_busy_timeout`, which the SQLite driver does not read, so the database
ran in rollback-journal mode. The default now uses the driver's
`_pragma=journal_mode(WAL)` and `_pragma=busy_timeout(5000)` parameters,
in one constant that both the config default and the database fallback
use. The database no longer sets its own busy timeout after opening, so
the URL's value applies. A new test opens a file database with those
parameters through db.New and checks the journal mode and busy timeout.
README.md's database text now matches, and an in-memory test database
drops the same ignored parameter.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-20 11:30:09 +02:00
clawbot eea90b01e5 Merge main into next (closes #132)
check / check (push) Canceled after 0s
main held only the data race and test timeout fix, which next already
has as 915f56e, so every conflict is resolved to next's version and the
merge changes no file.

Model: opus-5-5
2026-10-20 08:42:53 +00:00
clawbot a054bf828d Give each IRC listener test its own database (closes #129)
check / check (push) Canceled after 0s
Each test environment in internal/ircserver named its shared in-memory
database after the test. A repeat run of a test in the same process
could open the database that an earlier run's server was still
holding, and start on that run's users. The database now gets a random
name, and a new test checks that two environments open at once do not
share one.

The database close and the server stop are now registered as soon as
each is opened, so a setup that fails part-way still closes its
database.

Model: opus-5-5
2026-10-08 12:01:48 +02:00
clawbot e80c9552eb Bring README.md in line with the modes and the schema (closes #119)
check / check (push) Canceled after 0s
The MODE section described a query-only command. It now covers channel
and user mode queries and changes on the HTTP API and the IRC listener,
the letters each accepts, what is broadcast, and the error numerics.
The schema section now lists every table and column in
001_initial.sql.

Every other statement this change touches was checked against the code:
polled numerics carry their name in command and their number in code;
command errors are numerics with HTTP 200; the broker is keyed by
session; WAL is never on; a hostname falls back to the IP and is
public; and the IRC listener handles modes, NOTICE, INVITE, +s and +H
more narrowly, with gaps in its bridge to the HTTP API.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-08 11:30:48 +02:00
clawbot ccb2bf7960 Keep active IRC listener users from expiring (closes #121)
check / check (push) Canceled after 0s
Every command a registered IRC listener client sends, PING and PONG
included, now sets last_seen on its session and its client, so the
idle cleanup no longer removes an IRC user who is still active; one
who sends nothing for SESSION_IDLE_TIMEOUT is still removed. The two
UPDATEs that GetSessionByToken ran for every authenticated HTTP API
request move into UpdateLastSeen, which both transports now call. The
IRC listener refreshes on every command, with no interval between
writes: that is what every HTTP API request already costs. README.md
describes last_seen to match.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-08 10:47:55 +02:00
clawbot 0f2e63d0cc Add the tier 3 utility IRC commands (closes #87)
check / check (push) Canceled after 0s
USERHOST, VERSION, ADMIN, INFO, TIME, KILL and WALLOPS work on the
HTTP API and on the IRC listener. The USERHOST reply, the VERSION,
ADMIN and INFO text, KILL and WALLOPS are each one service function
that both transports call. User mode +w selects who receives WALLOPS.
MODE on your own nick queries or changes your user modes through one
parser on both transports: the whole mode string is checked before
anything is stored, and both flags are stored in one transaction. KILL
closes the victim's IRC connection after sending it KILL and ERROR,
without the operator waiting on it. The sessions table gains
is_wallops in 001_initial.sql, as the pre-1.0 rule in REPO_POLICIES.md
asks.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-08 07:31:37 +02:00
clawbot e3324407ca Fix the data races and the test timeout that fail make test (closes #104)
check / check (push) Successful in 1m19s
The HTTP server built its router inside the goroutine that starts
serving, so the start hook returned before the router existed, and
the internal/handlers tests raced with it or hit a nil router. The
start hook now runs configure, enableSentry and SetupRoutes, in that
order, then serves in the background.

The goroutine that sends queued messages to an IRC client read c.nick
without c.mu while NICK changed it. Every such read now takes the lock.

Under -race in the Docker build, internal/handlers takes over 30s on
database work, not clock waits, so both go test runs in make test use
-timeout 120s. The || retry stays
(#101).

Model: opus-5-5
2026-10-02 10:13:28 +02:00
45 changed files with 10456 additions and 1832 deletions
+824 -471
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -47,6 +47,16 @@ func (b *Broker) Notify(userID int64) {
} }
} }
// ListenerCount returns how many channels from Wait are still
// registered for the user: those that neither Notify nor Remove
// has taken out.
func (b *Broker) ListenerCount(userID int64) int {
b.mu.Lock()
defer b.mu.Unlock()
return len(b.listeners[userID])
}
// Remove removes a specific wait channel (for cleanup on timeout). // Remove removes a specific wait channel (for cleanup on timeout).
func (b *Broker) Remove( func (b *Broker) Remove(
userID int64, userID int64,
+33
View File
@@ -59,6 +59,39 @@ func TestRemove(t *testing.T) {
} }
} }
func TestListenerCount(t *testing.T) {
t.Parallel()
brk := broker.New()
wantCount := func(step string, userID int64, want int) {
t.Helper()
got := brk.ListenerCount(userID)
if got != want {
t.Errorf("%s: user %d: got %d, want %d",
step, userID, got, want)
}
}
wantCount("before Wait", 1, 0)
waitCh := brk.Wait(1)
brk.Wait(1)
brk.Wait(2)
wantCount("after two Waits", 1, 2)
brk.Remove(1, waitCh)
wantCount("after Remove", 1, 1)
brk.Notify(1)
wantCount("after Notify", 1, 0)
wantCount("after Notify", 2, 1)
}
func TestMultipleWaiters(t *testing.T) { func TestMultipleWaiters(t *testing.T) {
t.Parallel() t.Parallel()
+2 -2
View File
@@ -250,7 +250,7 @@ func (client *Client) ListChannels() (
// GetMembers returns members of a channel. // GetMembers returns members of a channel.
func (client *Client) GetMembers( func (client *Client) GetMembers(
channel string, channel string,
) ([]string, error) { ) ([]Member, error) {
name := strings.TrimPrefix(channel, "#") name := strings.TrimPrefix(channel, "#")
data, err := client.do( data, err := client.do(
@@ -263,7 +263,7 @@ func (client *Client) GetMembers(
return nil, err return nil, err
} }
var members []string var members []Member
err = json.Unmarshal(data, &members) err = json.Unmarshal(data, &members)
if err != nil { if err != nil {
+366 -7
View File
@@ -2,18 +2,28 @@ package neoircapi_test
import ( import (
"context" "context"
"encoding/json"
"fmt"
"io" "io"
"net" "net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"reflect"
"slices"
"strconv"
"strings" "strings"
"testing" "testing"
api "sneak.berlin/go/neoirc/internal/cli/api" api "sneak.berlin/go/neoirc/internal/cli/api"
"sneak.berlin/go/neoirc/internal/hashcash"
) )
const cookieValue = "opaque-value" const (
cookieValue = "opaque-value"
testNick = "alice"
testChannel = "#general"
)
// newSessionServer starts a plain-HTTP server that, like // newSessionServer starts a plain-HTTP server that, like
// neoircd, sets a Secure auth cookie when a session is // neoircd, sets a Secure auth cookie when a session is
@@ -24,10 +34,16 @@ func newSessionServer(t *testing.T) *httptest.Server {
mux := http.NewServeMux() mux := http.NewServeMux()
// Without hashcash the server sends no hashcash_bits.
mux.HandleFunc("GET /api/v1/server", func( mux.HandleFunc("GET /api/v1/server", func(
writer http.ResponseWriter, _ *http.Request, writer http.ResponseWriter, _ *http.Request,
) { ) {
_, _ = io.WriteString(writer, `{}`) _, _ = io.WriteString(writer, `{
"name": "My NeoIRC Server",
"version": "0.1.0",
"motd": "Welcome! Be nice.",
"users": 42
}`)
}) })
mux.HandleFunc("POST /api/v1/session", func( mux.HandleFunc("POST /api/v1/session", func(
@@ -56,9 +72,14 @@ func newSessionServer(t *testing.T) *httptest.Server {
return return
} }
_, _ = io.WriteString( _, _ = io.WriteString(writer, `{
writer, `{"id":1,"nick":"alice","channels":[]}`, "id": 1,
) "nick": "alice",
"channels": [
{"id": 1, "name": "#general", "topic": "Welcome!"},
{"id": 2, "name": "#dev", "topic": ""}
]
}`)
}) })
server := httptest.NewServer(mux) server := httptest.NewServer(mux)
@@ -67,6 +88,344 @@ func newSessionServer(t *testing.T) *httptest.Server {
return server return server
} }
// newClientForPath starts a server that answers GET path with
// body, and returns a client for it.
func newClientForPath(t *testing.T, path, body string) *api.Client {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("GET "+path, func(
writer http.ResponseWriter, _ *http.Request,
) {
_, _ = io.WriteString(writer, body)
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
return api.NewClient(server.URL)
}
func TestGetStateDecodesFields(t *testing.T) {
t.Parallel()
client := api.NewClient(newSessionServer(t).URL)
_, err := client.CreateSession(testNick)
if err != nil {
t.Fatalf("create session: %v", err)
}
state, err := client.GetState()
if err != nil {
t.Fatalf("state: %v", err)
}
want := api.StateResponse{
ID: 1,
Nick: testNick,
Channels: []api.Channel{
{Name: testChannel, Topic: "Welcome!"},
{Name: "#dev", Topic: ""},
},
}
if !reflect.DeepEqual(*state, want) {
t.Fatalf("state: got %+v, want %+v", *state, want)
}
}
func TestGetServerInfoDecodesFields(t *testing.T) {
t.Parallel()
client := newClientForPath(t, "/api/v1/server", `{
"name": "My NeoIRC Server",
"version": "0.1.0",
"motd": "Welcome! Be nice.",
"users": 42,
"hashcash_bits": 20
}`)
info, err := client.GetServerInfo()
if err != nil {
t.Fatalf("server info: %v", err)
}
want := api.ServerInfo{
Name: "My NeoIRC Server",
MOTD: "Welcome! Be nice.",
Version: "0.1.0",
HashcashBits: 20,
}
if *info != want {
t.Fatalf("server info: got %+v, want %+v", *info, want)
}
}
// TestCreateSessionSendsNickAndStamp serves a server that, like
// neoircd with hashcash on, advertises hashcash_bits and creates
// the session only for a pow_token the server's validator in
// internal/hashcash accepts.
func TestCreateSessionSendsNickAndStamp(t *testing.T) {
t.Parallel()
const (
serverName = "My NeoIRC Server"
stampBits = 8
sessionID = 7
)
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/server", func(
writer http.ResponseWriter, _ *http.Request,
) {
_, _ = fmt.Fprintf(writer, `{
"name": %q,
"version": "0.1.0",
"motd": "Welcome! Be nice.",
"users": 42,
"hashcash_bits": %d
}`, serverName, stampBits)
})
validator := hashcash.NewValidator(serverName)
mux.HandleFunc("POST /api/v1/session", func(
writer http.ResponseWriter, request *http.Request,
) {
// Decoded with the field names handleCreateSession uses.
var payload struct {
Nick string `json:"nick"`
Hashcash string `json:"pow_token"` //nolint:tagliatelle
}
err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil {
writer.WriteHeader(http.StatusBadRequest)
return
}
err = validator.Claim(payload.Hashcash, stampBits)
if err != nil {
writer.WriteHeader(http.StatusPaymentRequired)
return
}
writer.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(writer).Encode(map[string]any{
"id": sessionID,
"nick": payload.Nick,
})
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
session, err := api.NewClient(server.URL).CreateSession(testNick)
if err != nil {
t.Fatalf("create session: %v", err)
}
want := api.SessionResponse{ID: sessionID, Nick: testNick}
if *session != want {
t.Fatalf("session: got %+v, want %+v", *session, want)
}
}
func TestListChannelsDecodesNamesAndTopics(t *testing.T) {
t.Parallel()
client := newClientForPath(t, "/api/v1/channels", `[
{"id": 1, "name": "#general", "topic": "Welcome!"},
{"id": 2, "name": "#dev", "topic": "Development discussion"}
]`)
channels, err := client.ListChannels()
if err != nil {
t.Fatalf("list channels: %v", err)
}
want := []api.Channel{
{Name: testChannel, Topic: "Welcome!"},
{Name: "#dev", Topic: "Development discussion"},
}
if !slices.Equal(channels, want) {
t.Fatalf("channels: got %+v, want %+v", channels, want)
}
}
func TestGetMembersDecodesMemberObjects(t *testing.T) {
t.Parallel()
client := newClientForPath(t, "/api/v1/channels/general/members", `[
{
"id": 1, "nick": "alice", "username": "alice",
"hostname": "host.example.com",
"isOperator": true, "isVoiced": false,
"lastSeen": "2026-02-10T20:00:00Z"
},
{
"id": 2, "nick": "bob", "username": "bob",
"hostname": "host.example.com",
"isOperator": false, "isVoiced": true,
"lastSeen": "2026-02-10T20:00:00Z"
},
{
"id": 3, "nick": "Carol", "username": "carol",
"hostname": "host.example.com",
"isOperator": false, "isVoiced": false,
"lastSeen": "2026-02-10T20:00:00Z"
}
]`)
members, err := client.GetMembers(testChannel)
if err != nil {
t.Fatalf("get members: %v", err)
}
want := []api.Member{
{Nick: testNick, IsOperator: true, IsVoiced: false},
{Nick: "bob", IsOperator: false, IsVoiced: true},
{Nick: "Carol", IsOperator: false, IsVoiced: false},
}
if !slices.Equal(members, want) {
t.Fatalf("members: got %+v, want %+v", members, want)
}
}
// TestPollMessagesAdvancesCursor serves the queue as the server
// does: entries after the cursor, and last_id as the new cursor.
func TestPollMessagesAdvancesCursor(t *testing.T) {
t.Parallel()
const lastQueueID = 42
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/messages", func(
writer http.ResponseWriter, request *http.Request,
) {
after := request.URL.Query().Get("after")
if after == strconv.Itoa(lastQueueID) {
_, _ = io.WriteString(
writer, `{"messages": [], "last_id": 42}`,
)
return
}
_, _ = io.WriteString(writer, `{
"messages": [{
"id": "b7c8210f-849c-4b90-9ee8-d99c8889358e",
"command": "PRIVMSG",
"from": "alice",
"to": "#general",
"body": ["hello world"],
"ts": "2026-02-10T20:00:01.234567891Z",
"meta": {}
}],
"last_id": 42
}`)
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
client := api.NewClient(server.URL)
first, err := client.PollMessages(0, 0)
if err != nil {
t.Fatalf("first poll: %v", err)
}
if first.LastID != lastQueueID || len(first.Messages) != 1 {
t.Fatalf(
"first poll: got last ID %d and %d messages, "+
"want %d and 1",
first.LastID, len(first.Messages), lastQueueID,
)
}
second, err := client.PollMessages(first.LastID, 0)
if err != nil {
t.Fatalf("second poll: %v", err)
}
if len(second.Messages) != 0 {
t.Fatalf(
"second poll: got %d messages again, want none",
len(second.Messages),
)
}
}
// TestPollMessagesDecodesMessageFields serves a relayed PRIVMSG
// with the sender's meta and a numeric, as the server queues
// them.
func TestPollMessagesDecodesMessageFields(t *testing.T) {
t.Parallel()
client := newClientForPath(t, "/api/v1/messages", `{
"messages": [
{
"id": "b7c8210f-849c-4b90-9ee8-d99c8889358e",
"command": "PRIVMSG",
"from": "alice",
"to": "#general",
"body": ["signed message"],
"ts": "2026-02-10T20:00:01.234567891Z",
"meta": {"sig": "base64...", "alg": "ed25519"}
},
{
"id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5",
"command": "RPL_LUSEROP",
"code": 252,
"from": "neoirc",
"to": "alice",
"params": ["0"],
"body": ["operator(s) online"],
"ts": "2026-02-10T20:00:02Z",
"meta": {}
}
],
"last_id": 42
}`)
result, err := client.PollMessages(0, 0)
if err != nil {
t.Fatalf("poll: %v", err)
}
want := []api.Message{
{
ID: "b7c8210f-849c-4b90-9ee8-d99c8889358e",
Command: "PRIVMSG",
From: testNick,
To: testChannel,
Body: []any{"signed message"},
TS: "2026-02-10T20:00:01.234567891Z",
Meta: map[string]any{"sig": "base64...", "alg": "ed25519"},
},
{
ID: "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5",
Command: "RPL_LUSEROP",
From: "neoirc",
To: testNick,
Params: []string{"0"},
Body: []any{"operator(s) online"},
TS: "2026-02-10T20:00:02Z",
Meta: map[string]any{},
},
}
if !reflect.DeepEqual(result.Messages, want) {
t.Fatalf("messages: got %+v, want %+v", result.Messages, want)
}
}
func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) { func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) {
t.Parallel() t.Parallel()
@@ -85,7 +444,7 @@ func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) {
"http://" + net.JoinHostPort(host, serverURL.Port()), "http://" + net.JoinHostPort(host, serverURL.Port()),
) )
_, err := client.CreateSession("alice") _, err := client.CreateSession(testNick)
if err != nil { if err != nil {
t.Fatalf("create session: %v", err) t.Fatalf("create session: %v", err)
} }
@@ -118,7 +477,7 @@ func TestClientWithholdsCookieOverPlainHTTPFromOtherHosts(t *testing.T) {
}, },
} }
_, err := client.CreateSession("alice") _, err := client.CreateSession(testNick)
if err != nil { if err != nil {
t.Fatalf("create session: %v", err) t.Fatalf("create session: %v", err)
} }
+16 -9
View File
@@ -16,9 +16,9 @@ type SessionResponse struct {
// StateResponse is the response from GET /api/v1/state. // StateResponse is the response from GET /api/v1/state.
type StateResponse struct { type StateResponse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Nick string `json:"nick"` Nick string `json:"nick"`
Channels []string `json:"channels"` Channels []Channel `json:"channels"`
} }
// Message represents a neoirc message envelope. // Message represents a neoirc message envelope.
@@ -53,12 +53,19 @@ func (m *Message) BodyLines() []string {
} }
} }
// Channel represents a channel in the list response. // Channel is a channel as GET /api/v1/channels and
// GET /api/v1/state describe it.
type Channel struct { type Channel struct {
Name string `json:"name"` Name string `json:"name"`
Topic string `json:"topic"` Topic string `json:"topic"`
Members int `json:"members"` }
CreatedAt string `json:"createdAt"`
// Member is one entry of the response from
// GET /api/v1/channels/{name}/members.
type Member struct {
Nick string `json:"nick"`
IsOperator bool `json:"isOperator"`
IsVoiced bool `json:"isVoiced"`
} }
// ServerInfo is the response from GET /api/v1/server. // ServerInfo is the response from GET /api/v1/server.
@@ -72,7 +79,7 @@ type ServerInfo struct {
// MessagesResponse wraps polling results. // MessagesResponse wraps polling results.
type MessagesResponse struct { type MessagesResponse struct {
Messages []Message `json:"messages"` Messages []Message `json:"messages"`
LastID int64 `json:"lastId"` LastID int64 `json:"last_id"` //nolint:tagliatelle
} }
// PollResult wraps the poll response including the cursor. // PollResult wraps the poll response including the cursor.
+32 -16
View File
@@ -434,16 +434,9 @@ func (a *App) cmdTopic(args string) {
return return
} }
// The HTTP API has no topic query: a TOPIC needs text.
if args == "" { if args == "" {
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct a.ui.AddStatus("[red]Usage: /topic <text>")
Command: irc.CmdTopic,
To: target,
})
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
"[red]Topic query failed: %v", err,
))
}
return return
} }
@@ -487,12 +480,32 @@ func (a *App) cmdNames() {
return return
} }
nicks := make([]string, 0, len(members))
for _, member := range members {
nicks = append(nicks, memberPrefix(member)+member.Nick)
}
a.ui.AddLine(target, fmt.Sprintf( a.ui.AddLine(target, fmt.Sprintf(
"[cyan]*** Members of %s: %s", "[cyan]*** Members of %s: %s",
target, strings.Join(members, " "), target, strings.Join(nicks, " "),
)) ))
} }
// memberPrefix returns "@" for an operator, "+" for a voiced
// member, and "" for anyone else.
func memberPrefix(member api.Member) string {
if member.IsOperator {
return "@"
}
if member.IsVoiced {
return "+"
}
return ""
}
func (a *App) cmdList() { func (a *App) cmdList() {
a.mu.Lock() a.mu.Lock()
connected := a.connected connected := a.connected
@@ -517,8 +530,7 @@ func (a *App) cmdList() {
for _, ch := range channels { for _, ch := range channels {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
" %s (%d members) %s", " %s %s", ch.Name, ch.Topic,
ch.Name, ch.Members, ch.Topic,
)) ))
} }
@@ -676,7 +688,7 @@ func (a *App) cmdHelp() {
" /part [#chan] — Leave channel", " /part [#chan] — Leave channel",
" /msg <nick> <text> — Send DM", " /msg <nick> <text> — Send DM",
" /query <nick> — Open DM window", " /query <nick> — Open DM window",
" /topic [text] — View/set topic", " /topic <text> — Set topic",
" /names — List channel members", " /names — List channel members",
" /list — List channels", " /list — List channels",
" /who [#channel] — List users in channel", " /who [#channel] — List users in channel",
@@ -745,7 +757,7 @@ func (a *App) handleServerMessage(msg *api.Message) {
case irc.CmdJoin: case irc.CmdJoin:
a.handleJoinEvent(msg, timestamp) a.handleJoinEvent(msg, timestamp)
case irc.CmdPart: case irc.CmdPart:
a.handlePartEvent(msg, timestamp) a.handlePartEvent(msg, timestamp, myNick)
case irc.CmdQuit: case irc.CmdQuit:
a.handleQuitEvent(msg, timestamp) a.handleQuitEvent(msg, timestamp)
case irc.CmdNick: case irc.CmdNick:
@@ -801,10 +813,14 @@ func (a *App) handleJoinEvent(
)) ))
} }
// handlePartEvent shows another user's part. The server
// also sends the user's own part back to it, which cmdPart
// has already shown; showing it again would add a second
// line and mark the channel just left as unread.
func (a *App) handlePartEvent( func (a *App) handlePartEvent(
msg *api.Message, timestamp string, msg *api.Message, timestamp, myNick string,
) { ) {
if msg.To == "" { if msg.To == "" || msg.From == myNick {
return return
} }
+116
View File
@@ -0,0 +1,116 @@
package cli
import (
"testing"
"github.com/gdamore/tcell/v2"
api "sneak.berlin/go/neoirc/internal/cli/api"
"sneak.berlin/go/neoirc/pkg/irc"
)
func TestMemberPrefix(t *testing.T) {
t.Parallel()
tests := []struct {
name string
member api.Member
want string
}{
{
name: "operator",
member: api.Member{Nick: "alice", IsOperator: true},
want: "@",
},
{
name: "voiced member",
member: api.Member{Nick: "bob", IsVoiced: true},
want: "+",
},
{
name: "voiced operator",
member: api.Member{
Nick: "Carol", IsOperator: true, IsVoiced: true,
},
want: "@",
},
{
name: "plain member",
member: api.Member{Nick: "dave"},
want: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
got := memberPrefix(test.member)
if got != test.want {
t.Fatalf("got %q, want %q", got, test.want)
}
})
}
}
// TestPartEvent checks that a PART event adds a line to the
// channel's buffer and marks it unread, except for the
// user's own part, which cmdPart has already shown.
func TestPartEvent(t *testing.T) {
t.Parallel()
tests := []struct {
name string
from string
wantLines int
wantUnread int
}{
{name: "own part", from: "erin", wantLines: 0, wantUnread: 0},
{name: "other member", from: "bob", wantLines: 1, wantUnread: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
app := &App{ui: NewUI(), nick: "erin"}
runUI(t, app.ui)
app.handleServerMessage(&api.Message{
Command: irc.CmdPart, From: test.from, To: "#chan",
})
var lines, unread int
app.ui.app.QueueUpdate(func() {
buf := app.ui.getOrCreateBuffer("#chan")
lines = len(buf.Lines)
unread = buf.Unread
})
if lines != test.wantLines || unread != test.wantUnread {
t.Fatalf("got %d lines and %d unread, want %d and %d",
lines, unread, test.wantLines, test.wantUnread)
}
})
}
}
// runUI runs ui on a simulated screen until the test ends.
func runUI(t *testing.T, ui *UI) {
t.Helper()
ui.app.SetScreen(tcell.NewSimulationScreen(""))
done := make(chan error)
go func() { done <- ui.Run() }()
t.Cleanup(func() {
ui.Stop()
err := <-done
if err != nil {
t.Errorf("run ui: %v", err)
}
})
}
+15 -1
View File
@@ -4,6 +4,7 @@ package config
import ( import (
"errors" "errors"
"log/slog" "log/slog"
"os"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/fx" "go.uber.org/fx"
@@ -21,6 +22,12 @@ const defaultMOTD = ` _ __ ___ ___ (_)_ __ ___
Welcome to NeoIRC — IRC semantics over HTTP. Welcome to NeoIRC — IRC semantics over HTTP.
Type /help for available commands.` Type /help for available commands.`
// DefaultDBURL is the database URL used when DBURL is not set. Its
// _pragma parameters, which the SQLite driver runs on every connection
// it opens, set WAL mode and a 5000 ms busy timeout.
const DefaultDBURL = "file:///var/lib/neoirc/state.db" +
"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
// Params defines the dependencies for creating a Config. // Params defines the dependencies for creating a Config.
type Params struct { type Params struct {
fx.In fx.In
@@ -71,7 +78,7 @@ func New(
viper.SetDefault("DEBUG", "false") viper.SetDefault("DEBUG", "false")
viper.SetDefault("MAINTENANCE_MODE", "false") viper.SetDefault("MAINTENANCE_MODE", "false")
viper.SetDefault("PORT", "8080") viper.SetDefault("PORT", "8080")
viper.SetDefault("DBURL", "file:///var/lib/neoirc/state.db?_journal_mode=WAL") viper.SetDefault("DBURL", DefaultDBURL)
viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "") viper.SetDefault("METRICS_PASSWORD", "")
@@ -123,6 +130,13 @@ func New(
params: &params, params: &params,
} }
// Viper counts an empty environment variable as unset, but an
// empty IRC_LISTEN_ADDR must turn the IRC listener off.
ircListenAddr, set := os.LookupEnv("IRC_LISTEN_ADDR")
if set {
cfg.IRCListenAddr = ircListenAddr
}
if cfg.Debug { if cfg.Debug {
params.Logger.EnableDebugLogging() params.Logger.EnableDebugLogging()
cfg.log = params.Logger.Get() cfg.log = params.Logger.Get()
+61
View File
@@ -0,0 +1,61 @@
package config_test
import (
"os"
"testing"
"time"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
)
func newConfig(t *testing.T) *config.Config {
t.Helper()
globs := &globals.Globals{
Appname: "neoirc-test",
Version: "test",
StartTime: time.Now(),
}
log, err := logger.New(nil, logger.Params{Globals: globs})
if err != nil {
t.Fatal(err)
}
cfg, err := config.New(
nil, config.Params{Globals: globs, Logger: log},
)
if err != nil {
t.Fatal(err)
}
return cfg
}
func TestIRCListenAddrUnsetKeepsDefault(t *testing.T) {
// t.Setenv puts back whatever value was there after the test.
t.Setenv("IRC_LISTEN_ADDR", "")
err := os.Unsetenv("IRC_LISTEN_ADDR")
if err != nil {
t.Fatal(err)
}
const want = ":6667"
cfg := newConfig(t)
if cfg.IRCListenAddr != want {
t.Errorf("IRCListenAddr = %q, want %q", cfg.IRCListenAddr, want)
}
}
func TestIRCListenAddrEmptyTurnsListenerOff(t *testing.T) {
t.Setenv("IRC_LISTEN_ADDR", "")
cfg := newConfig(t)
if cfg.IRCListenAddr != "" {
t.Errorf("IRCListenAddr = %q, want empty", cfg.IRCListenAddr)
}
}
+1 -8
View File
@@ -87,7 +87,7 @@ func (database *Database) GetDB() *sql.DB {
func (database *Database) connect(ctx context.Context) error { func (database *Database) connect(ctx context.Context) error {
dbURL := database.params.Config.DBURL dbURL := database.params.Config.DBURL
if dbURL == "" { if dbURL == "" {
dbURL = "file:///var/lib/neoirc/state.db?_journal_mode=WAL&_busy_timeout=5000" dbURL = config.DefaultDBURL
} }
database.log.Info( database.log.Info(
@@ -116,13 +116,6 @@ func (database *Database) connect(ctx context.Context) error {
return fmt.Errorf("enable foreign keys: %w", err) return fmt.Errorf("enable foreign keys: %w", err)
} }
_, err = database.conn.ExecContext(
ctx, "PRAGMA busy_timeout = 5000",
)
if err != nil {
return fmt.Errorf("set busy timeout: %w", err)
}
return database.runMigrations(ctx) return database.runMigrations(ctx)
} }
+75
View File
@@ -0,0 +1,75 @@
package db_test
import (
"path/filepath"
"strings"
"testing"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
)
const wantBusyTimeoutMS = 5000
// TestDefaultDBURLSetsWALAndBusyTimeout opens a file database
// with the default URL's parameters the way the server does,
// and checks the journal mode and busy timeout it ends up with.
func TestDefaultDBURLSetsWALAndBusyTimeout(t *testing.T) {
t.Parallel()
_, params, _ := strings.Cut(config.DefaultDBURL, "?")
dbURL := "file://" + filepath.Join(t.TempDir(), "state.db") +
"?" + params
lifecycle := fxtest.NewLifecycle(t)
log, err := logger.New(lifecycle, logger.Params{
Globals: &globals.Globals{},
})
if err != nil {
t.Fatal(err)
}
database, err := db.New(lifecycle, db.Params{
Logger: log,
Config: &config.Config{DBURL: dbURL},
})
if err != nil {
t.Fatal(err)
}
lifecycle.RequireStart()
t.Cleanup(lifecycle.RequireStop)
var journalMode string
err = database.GetDB().QueryRowContext(
t.Context(), "PRAGMA journal_mode",
).Scan(&journalMode)
if err != nil {
t.Fatal(err)
}
if journalMode != "wal" {
t.Errorf("journal_mode = %q, want %q", journalMode, "wal")
}
var busyTimeout int
err = database.GetDB().QueryRowContext(
t.Context(), "PRAGMA busy_timeout",
).Scan(&busyTimeout)
if err != nil {
t.Fatal(err)
}
if busyTimeout != wantBusyTimeoutMS {
t.Errorf(
"busy_timeout = %d, want %d",
busyTimeout, wantBusyTimeoutMS,
)
}
}
+11
View File
@@ -17,3 +17,14 @@ func IsUniqueConstraintError(err error) bool {
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE
} }
// IsForeignKeyConstraintError reports whether err is a
// SQLite foreign-key constraint violation.
func IsForeignKeyConstraintError(err error) bool {
var sqliteErr *sqlite.Error
if !errors.As(err, &sqliteErr) {
return false
}
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY
}
+14
View File
@@ -58,3 +58,17 @@ func (database *Database) Close() error {
return nil return nil
} }
// ExecForTest runs a raw statement, such as a trigger that
// makes a write fail.
func (database *Database) ExecForTest(
ctx context.Context,
query string,
) error {
_, err := database.conn.ExecContext(ctx, query)
if err != nil {
return fmt.Errorf("exec for test: %w", err)
}
return nil
}
+719 -193
View File
File diff suppressed because it is too large Load Diff
+330 -39
View File
@@ -2,6 +2,7 @@ package db_test
import ( import (
"encoding/json" "encoding/json"
"slices"
"testing" "testing"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
@@ -411,7 +412,7 @@ func TestJoinAndPart(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
err = database.PartChannel(ctx, chID, sid) err = database.PartChannelAndDeleteIfEmpty(ctx, chID, sid)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -422,7 +423,10 @@ func TestJoinAndPart(t *testing.T) {
} }
} }
func TestDeleteChannelIfEmpty(t *testing.T) { // TestPartChannelAndDeleteIfEmpty checks that a part keeps
// a channel another session is still on and deletes the
// channel when its last member parts.
func TestPartChannelAndDeleteIfEmpty(t *testing.T) {
t.Parallel() t.Parallel()
database := setupTestDB(t) database := setupTestDB(t)
@@ -435,22 +439,34 @@ func TestDeleteChannelIfEmpty(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
sid, _, _, err := database.CreateSession(ctx, "temp", "", "", "") nicks := []string{"temp1", "temp2"}
sids := make([]int64, 0, len(nicks))
for _, nick := range nicks {
sid, _, _, createErr := database.CreateSession(ctx, nick, "", "", "")
if createErr != nil {
t.Fatal(createErr)
}
err = database.JoinChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
sids = append(sids, sid)
}
err = database.PartChannelAndDeleteIfEmpty(ctx, chID, sids[0])
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
err = database.JoinChannel(ctx, chID, sid) _, err = database.GetChannelByName(ctx, "#empty")
if err != nil { if err != nil {
t.Fatal(err) t.Fatalf("channel deleted while a member is on it: %v", err)
} }
err = database.PartChannel(ctx, chID, sid) err = database.PartChannelAndDeleteIfEmpty(ctx, chID, sids[1])
if err != nil {
t.Fatal(err)
}
err = database.DeleteChannelIfEmpty(ctx, chID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -619,7 +635,7 @@ func TestInsertMessage(t *testing.T) {
body := json.RawMessage(`["hello"]`) body := json.RawMessage(`["hello"]`)
dbID, msgUUID, err := database.InsertMessage( dbID, msgUUID, err := database.InsertMessage(
ctx, "PRIVMSG", "poller", "#test", nil, body, nil, ctx, "PRIVMSG", "poller", "#test", 0, 0, nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -653,13 +669,13 @@ func TestPollMessages(t *testing.T) {
body := json.RawMessage(`["hello"]`) body := json.RawMessage(`["hello"]`)
dbID, _, err := database.InsertMessage( dbID, _, err := database.InsertMessage(
ctx, "PRIVMSG", "poller", "#test", nil, body, nil, ctx, "PRIVMSG", "poller", "#test", 0, 0, nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
err = database.EnqueueToSession(ctx, sid, dbID) err = database.EnqueueToSession(ctx, sid, dbID, 0)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -706,11 +722,16 @@ func TestGetHistory(t *testing.T) {
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
chID, err := database.GetOrCreateChannel(ctx, "#hist")
if err != nil {
t.Fatal(err)
}
const msgCount = 10 const msgCount = 10
for range msgCount { for range msgCount {
_, _, err := database.InsertMessage( _, _, err = database.InsertMessage(
ctx, "PRIVMSG", "user", "#hist", ctx, "PRIVMSG", "user", "#hist", 0, chID,
nil, json.RawMessage(`["msg"]`), nil, nil, json.RawMessage(`["msg"]`), nil,
) )
if err != nil { if err != nil {
@@ -721,7 +742,7 @@ func TestGetHistory(t *testing.T) {
const histLimit = 5 const histLimit = 5
msgs, err := database.GetHistory( msgs, err := database.GetHistory(
ctx, "#hist", 0, histLimit, ctx, 0, chID, "", histLimit,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -737,44 +758,209 @@ func TestGetHistory(t *testing.T) {
} }
} }
func TestDeleteSession(t *testing.T) { // TestGetHistoryReusedNames checks that history follows the
// session and the channel row, not the nick or the channel
// name: a session that takes a released nick, and a channel
// created again under a deleted one's name, start with none.
func TestGetHistoryReusedNames(t *testing.T) {
t.Parallel() t.Parallel()
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
sid, _, _, err := database.CreateSession( historyLen := func(toSessionID, toChannelID int64) int {
ctx, "deleteme", "", "", "", t.Helper()
msgs, err := database.GetHistory(
ctx, toSessionID, toChannelID, "", 0,
)
if err != nil {
t.Fatal(err)
}
return len(msgs)
}
oldSID, _, _, err := database.CreateSession(
ctx, "reused", "", "", "",
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
chID, err := database.GetOrCreateChannel( oldChID, err := database.GetOrCreateChannel(ctx, "#reused")
ctx, "#delchan", if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, oldChID, oldSID)
if err != nil {
t.Fatal(err)
}
body := json.RawMessage(`["hi"]`)
_, _, err = database.InsertMessage(
ctx, "PRIVMSG", "sender", "reused", oldSID, 0,
nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
err = database.JoinChannel(ctx, chID, sid) _, _, err = database.InsertMessage(
ctx, "PRIVMSG", "sender", "#reused", 0, oldChID,
nil, body, nil,
)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
err = database.DeleteSession(ctx, sid) if historyLen(oldSID, 0) != 1 || historyLen(0, oldChID) != 1 {
t.Fatal("expected one DM and one channel message")
}
// The session is the channel's only member, so quitting
// deletes the channel too.
_, err = database.QuitSession(ctx, oldSID, "reused", nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
newSID, _, _, err := database.CreateSession(
ctx, "reused", "", "", "",
)
if err != nil {
t.Fatal(err)
}
newChID, err := database.GetOrCreateChannel(ctx, "#reused")
if err != nil {
t.Fatal(err)
}
if historyLen(newSID, 0) != 0 || historyLen(0, newChID) != 0 {
t.Fatal("expected no history for the names' new holders")
}
}
// TestQuitSession checks that QuitSession deletes the
// session, keeps a channel another session is still on,
// deletes a channel it leaves with no members, and queues
// one QUIT to the other session; and that a second call for
// the same session changes nothing.
func TestQuitSession(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sid, sharedID, _ := createSessionWithChannels(
t, database, "deleteme", "#shared", "#alone",
)
peerSID, peerClientID, _, err := database.CreateSession(
ctx, "peer", "", "", "",
)
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, sharedID, peerSID)
if err != nil {
t.Fatal(err)
}
body := json.RawMessage(`["bye"]`)
peerIDs, err := database.QuitSession(ctx, sid, "deleteme", body)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(peerIDs, []int64{peerSID}) {
t.Fatalf("got peers %v, want [%d]", peerIDs, peerSID)
}
_, err = database.GetSessionByNick(ctx, "deleteme") _, err = database.GetSessionByNick(ctx, "deleteme")
if err == nil { if err == nil {
t.Fatal("session should be deleted") t.Fatal("session should be deleted")
} }
ids, _ := database.GetChannelMemberIDs(ctx, chID) ids, _ := database.GetChannelMemberIDs(ctx, sharedID)
if len(ids) != 0 { if !slices.Equal(ids, []int64{peerSID}) {
t.Fatal("expected no members after deletion") t.Fatalf("#shared members: got %v, want [%d]", ids, peerSID)
}
_, err = database.GetChannelByName(ctx, "#alone")
if err == nil {
t.Fatal("expected the emptied channel to be deleted")
}
checkOneQuitQueued(t, database, peerClientID, "deleteme")
peerIDs, err = database.QuitSession(ctx, sid, "deleteme", body)
if err != nil || len(peerIDs) != 0 {
t.Fatalf("second quit: got %v, %v; want none", peerIDs, err)
}
checkOneQuitQueued(t, database, peerClientID, "deleteme")
}
// checkOneQuitQueued fails the test unless the queue of the
// client clientID holds one message, a QUIT from nick.
func checkOneQuitQueued(
t *testing.T,
database *db.Database,
clientID int64,
nick string,
) {
t.Helper()
msgs, _, err := database.PollMessages(t.Context(), clientID, 0, 0)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 1 || msgs[0].Command != "QUIT" || msgs[0].From != nick {
t.Fatalf("queue: got %v, want one QUIT from %s", msgs, nick)
}
}
// TestLogoutClientFailureKeepsClient checks that when the
// session of a logout's last client cannot be deleted,
// LogoutClient fails and the client stays logged in.
func TestLogoutClientFailureKeepsClient(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sid, clientID, token, err := database.CreateSession(
ctx, "stuck", "", "", "",
)
if err != nil {
t.Fatal(err)
}
_, err = database.GetDB().ExecContext(ctx,
`CREATE TRIGGER keep_sessions
BEFORE DELETE ON sessions
BEGIN
SELECT RAISE(ABORT, 'sessions are kept');
END`)
if err != nil {
t.Fatal(err)
}
_, err = database.LogoutClient(ctx, sid, clientID, "stuck", nil)
if err == nil {
t.Fatal("logout succeeded although the session was kept")
}
_, _, nick, err := database.GetSessionByToken(ctx, token)
if err != nil || nick != "stuck" {
t.Fatalf("client gone after a failed logout: %q, %v", nick, err)
} }
} }
@@ -863,7 +1049,7 @@ func TestEnqueueToClient(t *testing.T) {
body := json.RawMessage(`["test"]`) body := json.RawMessage(`["test"]`)
dbID, _, err := database.InsertMessage( dbID, _, err := database.InsertMessage(
ctx, "PRIVMSG", "sender", "#ch", nil, body, nil, ctx, "PRIVMSG", "sender", "#ch", 0, 0, nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -1253,17 +1439,6 @@ func TestChannelInviteCRUD(t *testing.T) {
if !has { if !has {
t.Fatal("should have invite") t.Fatal("should have invite")
} }
// Clear invite.
err = database.ClearChannelInvite(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
has, _ = database.HasChannelInvite(ctx, chID, sid)
if has {
t.Fatal("invite should be cleared")
}
} }
func TestChannelSecret(t *testing.T) { func TestChannelSecret(t *testing.T) {
@@ -1491,3 +1666,119 @@ func TestChannelUserLimit(t *testing.T) {
t.Fatalf("expected 0, got %d", limit) t.Fatalf("expected 0, got %d", limit)
} }
} }
// TestSetSessionUserModesIsAtomic makes the is_oper write
// fail after the is_wallops write has run, and checks that
// the wallops change was rolled back.
func TestSetSessionUserModesIsAtomic(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, _, _, err := database.CreateSession(
ctx, "alice", "", "", "",
)
if err != nil {
t.Fatal(err)
}
err = database.ExecForTest(ctx,
`CREATE TRIGGER reject_oper
BEFORE UPDATE OF is_oper ON sessions
BEGIN SELECT RAISE(ABORT, 'oper write rejected');
END`,
)
if err != nil {
t.Fatal(err)
}
wallops := true
oper := false
err = database.SetSessionUserModes(
ctx, sessionID, &wallops, &oper,
)
if err == nil {
t.Fatal("expected the rejected oper write to fail")
}
gotWallops, err := database.IsSessionWallops(
ctx, sessionID,
)
if err != nil {
t.Fatal(err)
}
if gotWallops {
t.Error("wallops was stored although the change failed")
}
}
// TestSetSessionUserModesAppliesBoth checks that both flags
// are written, and that a nil flag is left as it is.
func TestSetSessionUserModesAppliesBoth(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, _, _, err := database.CreateSession(
ctx, "alice", "", "", "",
)
if err != nil {
t.Fatal(err)
}
err = database.SetSessionOper(ctx, sessionID, true)
if err != nil {
t.Fatal(err)
}
wallops := true
oper := false
err = database.SetSessionUserModes(
ctx, sessionID, &wallops, &oper,
)
if err != nil {
t.Fatal(err)
}
gotWallops, err := database.IsSessionWallops(
ctx, sessionID,
)
if err != nil {
t.Fatal(err)
}
gotOper, err := database.IsSessionOper(ctx, sessionID)
if err != nil {
t.Fatal(err)
}
if !gotWallops || gotOper {
t.Errorf(
"want wallops=true oper=false, got %v/%v",
gotWallops, gotOper,
)
}
err = database.SetSessionUserModes(
ctx, sessionID, nil, nil,
)
if err != nil {
t.Fatal(err)
}
gotWallops, err = database.IsSessionWallops(
ctx, sessionID,
)
if err != nil {
t.Fatal(err)
}
if !gotWallops {
t.Error("a nil wallops flag cleared wallops")
}
}
+9 -2
View File
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS sessions (
hostname TEXT NOT NULL DEFAULT '', hostname TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '', ip TEXT NOT NULL DEFAULT '',
is_oper INTEGER NOT NULL DEFAULT 0, is_oper INTEGER NOT NULL DEFAULT 0,
is_wallops INTEGER NOT NULL DEFAULT 0,
password_hash TEXT NOT NULL DEFAULT '', password_hash TEXT NOT NULL DEFAULT '',
signing_key TEXT NOT NULL DEFAULT '', signing_key TEXT NOT NULL DEFAULT '',
away_message TEXT NOT NULL DEFAULT '', away_message TEXT NOT NULL DEFAULT '',
@@ -84,19 +85,25 @@ CREATE TABLE IF NOT EXISTS channel_members (
UNIQUE(channel_id, session_id) UNIQUE(channel_id, session_id)
); );
-- Messages: IRC envelope format -- Messages: IRC envelope format. to_session_id is the session a DM
-- or INVITE was sent to, to_channel_id the channel a channel message
-- was sent to. History is selected by these ids because nicks and
-- channel names are reused once released, and ids are not.
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE, uuid TEXT NOT NULL UNIQUE,
command TEXT NOT NULL DEFAULT 'PRIVMSG', command TEXT NOT NULL DEFAULT 'PRIVMSG',
msg_from TEXT NOT NULL DEFAULT '', msg_from TEXT NOT NULL DEFAULT '',
msg_to TEXT NOT NULL DEFAULT '', msg_to TEXT NOT NULL DEFAULT '',
to_session_id INTEGER REFERENCES sessions(id) ON DELETE SET NULL,
to_channel_id INTEGER REFERENCES channels(id) ON DELETE SET NULL,
params TEXT NOT NULL DEFAULT '[]', params TEXT NOT NULL DEFAULT '[]',
body TEXT NOT NULL DEFAULT '[]', body TEXT NOT NULL DEFAULT '[]',
meta TEXT NOT NULL DEFAULT '{}', meta TEXT NOT NULL DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(msg_to, id); CREATE INDEX IF NOT EXISTS idx_messages_to_session ON messages(to_session_id, id);
CREATE INDEX IF NOT EXISTS idx_messages_to_channel ON messages(to_channel_id, id);
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at); CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
-- Spent hashcash tokens for replay prevention (1-year TTL) -- Spent hashcash tokens for replay prevention (1-year TTL)
+158 -413
View File
@@ -2,6 +2,7 @@ package handlers
import ( import (
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -14,24 +15,10 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc" "sneak.berlin/go/neoirc/pkg/irc"
) )
var (
errHashcashRequired = errors.New("hashcash required")
errHashcashReused = errors.New("hashcash reused")
)
var validNickRe = regexp.MustCompile(
`^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`,
)
var validChannelRe = regexp.MustCompile(
`^#[a-zA-Z0-9_\-]{1,63}$`,
)
var validUsernameRe = regexp.MustCompile( var validUsernameRe = regexp.MustCompile(
`^[a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{1,32}$`, `^[a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{1,32}$`,
) )
@@ -212,7 +199,7 @@ func (hdlr *Handlers) handleCreateSession(
return return
} }
if !hdlr.validateHashcash( if !hdlr.checkHashcash(
writer, request, payload.Hashcash, writer, request, payload.Hashcash,
) { ) {
return return
@@ -220,7 +207,7 @@ func (hdlr *Handlers) handleCreateSession(
payload.Nick = strings.TrimSpace(payload.Nick) payload.Nick = strings.TrimSpace(payload.Nick)
if !validNickRe.MatchString(payload.Nick) { if !service.ValidNick(payload.Nick) {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
"invalid nick format", "invalid nick format",
@@ -245,14 +232,15 @@ func (hdlr *Handlers) handleCreateSession(
} }
hdlr.executeCreateSession( hdlr.executeCreateSession(
writer, request, payload.Nick, username, writer, request,
payload.Nick, username, payload.Hashcash,
) )
} }
func (hdlr *Handlers) executeCreateSession( func (hdlr *Handlers) executeCreateSession(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
nick, username string, nick, username, stamp string,
) { ) {
remoteIP := clientIP(request) remoteIP := clientIP(request)
@@ -260,12 +248,17 @@ func (hdlr *Handlers) executeCreateSession(
request.Context(), remoteIP, request.Context(), remoteIP,
) )
if !hdlr.claimHashcash(writer, request, stamp) {
return
}
sessionID, clientID, token, err := sessionID, clientID, token, err :=
hdlr.params.Database.CreateSession( hdlr.params.Database.CreateSession(
request.Context(), request.Context(),
nick, username, hostname, remoteIP, nick, username, hostname, remoteIP,
) )
if err != nil { if err != nil {
hdlr.hashcashVal.Release(stamp)
hdlr.handleCreateSessionError( hdlr.handleCreateSessionError(
writer, request, err, writer, request, err,
) )
@@ -286,10 +279,10 @@ func (hdlr *Handlers) executeCreateSession(
}, http.StatusCreated) }, http.StatusCreated)
} }
// validateHashcash validates a hashcash stamp if required. // checkHashcash checks a hashcash stamp if required,
// Returns false if validation failed and a response was // without spending it. Returns false if the check failed
// already sent. // and a response was already sent.
func (hdlr *Handlers) validateHashcash( func (hdlr *Handlers) checkHashcash(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
stamp string, stamp string,
@@ -308,7 +301,31 @@ func (hdlr *Handlers) validateHashcash(
return false return false
} }
err := hdlr.hashcashVal.Validate( err := hdlr.hashcashVal.Check(
stamp, hdlr.params.Config.HashcashBits,
)
if err != nil {
hdlr.respondError(
writer, request,
"invalid hashcash stamp: "+err.Error(),
http.StatusPaymentRequired,
)
return false
}
return true
}
// claimHashcash spends a stamp that checkHashcash accepted.
// Returns false if Claim refused the stamp, normally because
// it was already spent, and a response was already sent.
func (hdlr *Handlers) claimHashcash(
writer http.ResponseWriter,
request *http.Request,
stamp string,
) bool {
err := hdlr.hashcashVal.Claim(
stamp, hdlr.params.Config.HashcashBits, stamp, hdlr.params.Config.HashcashBits,
) )
if err != nil { if err != nil {
@@ -579,7 +596,7 @@ func (hdlr *Handlers) enqueueNumeric(
} }
dbID, _, insertErr := hdlr.params.Database.InsertMessage( dbID, _, insertErr := hdlr.params.Database.InsertMessage(
ctx, command, hdlr.serverName(), nick, ctx, command, hdlr.serverName(), nick, 0, 0,
paramsJSON, json.RawMessage(body), nil, paramsJSON, json.RawMessage(body), nil,
) )
if insertErr != nil { if insertErr != nil {
@@ -665,7 +682,7 @@ func (hdlr *Handlers) initChannelState(
dbID, _, insErr := hdlr.params.Database. dbID, _, insErr := hdlr.params.Database.
InsertMessage( InsertMessage(
ctx, "JOIN", nick, chanInfo.Name, ctx, "JOIN", nick, chanInfo.Name,
nil, nil, nil, 0, chanInfo.ID, nil, nil, nil,
) )
if insErr != nil { if insErr != nil {
hdlr.log.Error( hdlr.log.Error(
@@ -796,7 +813,14 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc {
timeout = maxLongPollTimeout timeout = maxLongPollTimeout
} }
msgs, lastQID, err := hdlr.params.Database.PollMessages( // Register with the broker before reading the queue.
// Notify wakes only the channels registered at that
// moment, so a message queued after the read below
// must find this one there already.
waitCh := hdlr.broker.Wait(sessionID)
defer hdlr.broker.Remove(sessionID, waitCh)
msgs, lastQID, err := hdlr.pollMessages(
request.Context(), clientID, request.Context(), clientID,
afterID, pollMessageLimit, afterID, pollMessageLimit,
) )
@@ -824,19 +848,22 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc {
hdlr.longPoll( hdlr.longPoll(
writer, request, writer, request,
sessionID, clientID, afterID, timeout, waitCh, clientID, afterID, timeout,
) )
} }
} }
// longPoll waits up to timeout seconds for the broker to
// signal waitCh, then answers with the messages queued for
// clientID after afterID. It answers nothing if the client
// goes away first.
func (hdlr *Handlers) longPoll( func (hdlr *Handlers) longPoll(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID, clientID, afterID int64, waitCh <-chan struct{},
clientID, afterID int64,
timeout int, timeout int,
) { ) {
waitCh := hdlr.broker.Wait(sessionID)
timer := time.NewTimer( timer := time.NewTimer(
time.Duration(timeout) * time.Second, time.Duration(timeout) * time.Second,
) )
@@ -847,14 +874,10 @@ func (hdlr *Handlers) longPoll(
case <-waitCh: case <-waitCh:
case <-timer.C: case <-timer.C:
case <-request.Context().Done(): case <-request.Context().Done():
hdlr.broker.Remove(sessionID, waitCh)
return return
} }
hdlr.broker.Remove(sessionID, waitCh) msgs, lastQID, err := hdlr.pollMessages(
msgs, lastQID, err := hdlr.params.Database.PollMessages(
request.Context(), clientID, request.Context(), clientID,
afterID, pollMessageLimit, afterID, pollMessageLimit,
) )
@@ -1015,10 +1038,10 @@ func (hdlr *Handlers) dispatchCommand(
hdlr.handleQuit( hdlr.handleQuit(
writer, request, sessionID, nick, body, writer, request, sessionID, nick, body,
) )
case irc.CmdOper: case irc.CmdOper, irc.CmdKill, irc.CmdWallops:
hdlr.handleOper( hdlr.dispatchOperCommand(
writer, request, writer, request,
sessionID, clientID, nick, bodyLines, sessionID, clientID, nick, command, bodyLines,
) )
case irc.CmdMotd, irc.CmdPing: case irc.CmdMotd, irc.CmdPing:
hdlr.dispatchInfoCommand( hdlr.dispatchInfoCommand(
@@ -1075,6 +1098,27 @@ func (hdlr *Handlers) dispatchQueryCommand(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
) )
case irc.CmdUserhost:
hdlr.handleUserhost(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdVersion:
hdlr.handleVersion(
writer, request, sessionID, clientID, nick,
)
case irc.CmdAdmin:
hdlr.handleAdmin(
writer, request, sessionID, clientID, nick,
)
case irc.CmdInfo:
hdlr.handleInfo(
writer, request, sessionID, clientID, nick,
)
case irc.CmdTime:
hdlr.handleTime(
writer, request, sessionID, clientID, nick,
)
default: default:
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
request.Context(), clientID, request.Context(), clientID,
@@ -1213,26 +1257,11 @@ func (hdlr *Handlers) handleChannelMsg(
) { ) {
ctx := request.Context() ctx := request.Context()
// Hashcash validation is HTTP-specific; needs chID. // Delegate validation + fan-out to service layer. The
isNotice := command == irc.CmdNotice // fan-out includes the sender's own session, so all
// their clients see the message in long-poll responses.
if !isNotice { uuid, err := hdlr.svc.SendChannelMessage(
chID, chErr := hdlr.params.Database. ctx, sessionID, 0, nick,
GetChannelByName(ctx, target)
if chErr == nil {
hashcashErr := hdlr.validateChannelHashcash(
request, clientID, sessionID,
writer, nick, target, body, meta, chID,
)
if hashcashErr != nil {
return
}
}
}
// Delegate validation + fan-out to service layer.
dbID, uuid, err := hdlr.svc.SendChannelMessage(
ctx, sessionID, nick,
command, target, body, meta, command, target, body, meta,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1242,172 +1271,11 @@ func (hdlr *Handlers) handleChannelMsg(
return return
} }
// HTTP echo: enqueue to sender so all their clients
// see the message in long-poll responses.
_ = hdlr.params.Database.EnqueueToSession(
ctx, sessionID, dbID,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"id": uuid, statusKey: "sent"}, map[string]string{"id": uuid, statusKey: "sent"},
http.StatusOK) http.StatusOK)
} }
// validateChannelHashcash checks whether the channel
// requires hashcash proof-of-work for messages and
// validates the stamp from the message meta field.
// Returns nil on success or if the channel has no
// hashcash requirement. On failure, it sends the
// appropriate IRC error and returns a non-nil error.
func (hdlr *Handlers) validateChannelHashcash(
request *http.Request,
clientID, sessionID int64,
writer http.ResponseWriter,
nick, target string,
body json.RawMessage,
meta json.RawMessage,
chID int64,
) error {
ctx := request.Context()
bits, bitsErr := hdlr.params.Database.GetChannelHashcashBits(
ctx, chID,
)
if bitsErr != nil {
hdlr.log.Error(
"get channel hashcash bits", "error", bitsErr,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return fmt.Errorf("channel hashcash bits: %w", bitsErr)
}
if bits <= 0 {
return nil
}
stamp := hdlr.extractHashcashFromMeta(meta)
if stamp == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCannotSendToChan, nick, []string{target},
"Channel requires hashcash proof-of-work",
)
return errHashcashRequired
}
return hdlr.verifyChannelStamp(
request, writer,
clientID, sessionID,
nick, target, body, stamp, bits,
)
}
// verifyChannelStamp validates a channel hashcash stamp
// and checks for replay attacks.
func (hdlr *Handlers) verifyChannelStamp(
request *http.Request,
writer http.ResponseWriter,
clientID, sessionID int64,
nick, target string,
body json.RawMessage,
stamp string,
bits int,
) error {
ctx := request.Context()
bodyHashStr := hashcash.BodyHash(body)
valErr := hdlr.channelHashcash.ValidateStamp(
stamp, bits, target, bodyHashStr,
)
if valErr != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCannotSendToChan, nick, []string{target},
"Invalid hashcash: "+valErr.Error(),
)
return fmt.Errorf("channel hashcash: %w", valErr)
}
stampKey := hashcash.StampHash(stamp)
spent, spentErr := hdlr.params.Database.IsHashcashSpent(
ctx, stampKey,
)
if spentErr != nil {
hdlr.log.Error(
"check spent hashcash", "error", spentErr,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return fmt.Errorf("check spent hashcash: %w", spentErr)
}
if spent {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCannotSendToChan, nick, []string{target},
"Hashcash stamp already used",
)
return errHashcashReused
}
recordErr := hdlr.params.Database.RecordSpentHashcash(
ctx, stampKey,
)
if recordErr != nil {
hdlr.log.Error(
"record spent hashcash", "error", recordErr,
)
}
return nil
}
// extractHashcashFromMeta parses the meta JSON and
// returns the hashcash stamp string, or empty string
// if not present.
func (hdlr *Handlers) extractHashcashFromMeta(
meta json.RawMessage,
) string {
if len(meta) == 0 {
return ""
}
var metaMap map[string]json.RawMessage
err := json.Unmarshal(meta, &metaMap)
if err != nil {
return ""
}
raw, ok := metaMap["hashcash"]
if !ok {
return ""
}
var stamp string
err = json.Unmarshal(raw, &stamp)
if err != nil {
return ""
}
return stamp
}
func (hdlr *Handlers) handleDirectMsg( func (hdlr *Handlers) handleDirectMsg(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -1417,7 +1285,7 @@ func (hdlr *Handlers) handleDirectMsg(
meta json.RawMessage, meta json.RawMessage,
) { ) {
result, err := hdlr.svc.SendDirectMessage( result, err := hdlr.svc.SendDirectMessage(
request.Context(), sessionID, nick, request.Context(), sessionID, 0, nick,
command, target, body, meta, command, target, body, meta,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1467,16 +1335,6 @@ func (hdlr *Handlers) handleJoin(
channel = "#" + channel channel = "#" + channel
} }
if !validChannelRe.MatchString(channel) {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoSuchChannel, nick, []string{channel},
"No such channel",
)
return
}
// Extract key from body lines (JOIN #channel key). // Extract key from body lines (JOIN #channel key).
var suppliedKey string var suppliedKey string
@@ -1498,7 +1356,7 @@ func (hdlr *Handlers) executeJoin(
nick, channel, suppliedKey string, nick, channel, suppliedKey string,
) { ) {
result, err := hdlr.svc.JoinChannel( result, err := hdlr.svc.JoinChannel(
request.Context(), sessionID, nick, request.Context(), sessionID, 0, nick,
channel, suppliedKey, channel, suppliedKey,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1682,7 +1540,7 @@ func (hdlr *Handlers) handlePart(
} }
err := hdlr.svc.PartChannel( err := hdlr.svc.PartChannel(
request.Context(), sessionID, request.Context(), sessionID, 0,
nick, channel, reason, nick, channel, reason,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1720,7 +1578,7 @@ func (hdlr *Handlers) handleNick(
newNick := strings.TrimSpace(lines[0]) newNick := strings.TrimSpace(lines[0])
if !validNickRe.MatchString(newNick) { if !service.ValidNick(newNick) {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, clientID, sessionID, writer, request, clientID, sessionID,
irc.ErrErroneusNickname, nick, []string{newNick}, irc.ErrErroneusNickname, nick, []string{newNick},
@@ -1753,7 +1611,7 @@ func (hdlr *Handlers) executeNickChange(
nick, newNick string, nick, newNick string,
) { ) {
err := hdlr.svc.ChangeNick( err := hdlr.svc.ChangeNick(
request.Context(), sessionID, nick, newNick, request.Context(), sessionID, 0, nick, newNick,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
writer, request, writer, request,
@@ -1808,7 +1666,7 @@ func (hdlr *Handlers) handleTopic(
topic := strings.Join(lines, " ") topic := strings.Join(lines, " ")
err := hdlr.svc.SetTopic( err := hdlr.svc.SetTopic(
request.Context(), sessionID, request.Context(), sessionID, 0,
nick, channel, topic, nick, channel, topic,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1930,8 +1788,9 @@ func (hdlr *Handlers) handleQuit(
http.StatusOK) http.StatusOK)
} }
// handleMode handles the MODE command for channels and // handleMode handles the MODE command: a target that starts
// users. Currently supports query-only (no mode changes). // with # is a channel, handled by handleChannelMode, and any
// other target a nick, handled by handleUserMode.
func (hdlr *Handlers) handleMode( func (hdlr *Handlers) handleMode(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -1951,15 +1810,11 @@ func (hdlr *Handlers) handleMode(
channel := target channel := target
if !strings.HasPrefix(channel, "#") { if !strings.HasPrefix(channel, "#") {
// User mode query — return empty modes. hdlr.handleUserMode(
hdlr.enqueueNumeric( writer, request,
request.Context(), clientID, sessionID, clientID, nick, target,
irc.RplUmodeIs, nick, nil, "+", bodyLines,
) )
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"},
http.StatusOK)
return return
} }
@@ -2297,7 +2152,7 @@ func (hdlr *Handlers) applyUserMode(
modeText := modeArgs[0] + " " + targetNick modeText := modeArgs[0] + " " + targetNick
hdlr.svc.BroadcastMode( hdlr.svc.BroadcastMode(
request.Context(), nick, channel, chID, request.Context(), 0, nick, channel, chID,
modeText, modeText,
) )
@@ -2343,7 +2198,7 @@ func (hdlr *Handlers) setChannelFlag(
} }
hdlr.svc.BroadcastMode( hdlr.svc.BroadcastMode(
request.Context(), nick, channel, chID, request.Context(), 0, nick, channel, chID,
modeStr, modeStr,
) )
@@ -2580,7 +2435,7 @@ func (hdlr *Handlers) executeBanChange(
) )
if mErr == nil { if mErr == nil {
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdMode, nick, channel, request, irc.CmdMode, nick, channel, 0, chID,
json.RawMessage(modeBody), memberIDs, json.RawMessage(modeBody), memberIDs,
) )
} }
@@ -2698,7 +2553,7 @@ func (hdlr *Handlers) setChannelKeyMode(
modeBody, mErr := json.Marshal([]string{"+k", key}) modeBody, mErr := json.Marshal([]string{"+k", key})
if mErr == nil { if mErr == nil {
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdMode, nick, channel, request, irc.CmdMode, nick, channel, 0, chID,
json.RawMessage(modeBody), memberIDs, json.RawMessage(modeBody), memberIDs,
) )
} }
@@ -2747,7 +2602,7 @@ func (hdlr *Handlers) clearChannelKeyMode(
modeBody, mErr := json.Marshal([]string{"-k", "*"}) modeBody, mErr := json.Marshal([]string{"-k", "*"})
if mErr == nil { if mErr == nil {
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdMode, nick, channel, request, irc.CmdMode, nick, channel, 0, chID,
json.RawMessage(modeBody), memberIDs, json.RawMessage(modeBody), memberIDs,
) )
} }
@@ -2822,7 +2677,7 @@ func (hdlr *Handlers) setChannelLimitMode(
) )
if mErr == nil { if mErr == nil {
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdMode, nick, channel, request, irc.CmdMode, nick, channel, 0, chID,
json.RawMessage(modeBody), memberIDs, json.RawMessage(modeBody), memberIDs,
) )
} }
@@ -2872,7 +2727,7 @@ func (hdlr *Handlers) clearChannelLimitMode(
modeBody, mErr := json.Marshal([]string{"-l"}) modeBody, mErr := json.Marshal([]string{"-l"})
if mErr == nil { if mErr == nil {
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdMode, nick, channel, request, irc.CmdMode, nick, channel, 0, chID,
json.RawMessage(modeBody), memberIDs, json.RawMessage(modeBody), memberIDs,
) )
} }
@@ -2909,123 +2764,15 @@ func (hdlr *Handlers) handleInvite(
channel = "#" + channel channel = "#" + channel
} }
chID, targetSID, ok := hdlr.validateInvite(
writer, request,
sessionID, clientID, nick,
targetNick, channel,
)
if !ok {
return
}
hdlr.executeInvite(
writer, request,
sessionID, clientID, nick,
targetNick, channel, chID, targetSID,
)
}
// validateInvite checks channel, membership, permissions,
// and target for an INVITE command.
func (hdlr *Handlers) validateInvite(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, targetNick, channel string,
) (int64, int64, bool) {
ctx := request.Context() ctx := request.Context()
chID, err := hdlr.params.Database.GetChannelByName( targetSID, err := hdlr.svc.InviteUser(
ctx, channel, ctx, sessionID, nick, targetNick, channel,
) )
if err != nil { if hdlr.handleServiceError(
hdlr.respondIRCError( writer, request,
writer, request, clientID, sessionID, clientID, sessionID, nick, err,
irc.ErrNoSuchChannel, nick, ) {
[]string{channel}, "No such channel",
)
return 0, 0, false
}
isMember, memErr := hdlr.params.Database.IsChannelMember(
ctx, chID, sessionID,
)
if memErr != nil || !isMember {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNotOnChannel, nick,
[]string{channel},
"You're not on that channel",
)
return 0, 0, false
}
isInviteOnly, ioErr := hdlr.params.Database.
IsChannelInviteOnly(ctx, chID)
if ioErr == nil && isInviteOnly {
if !hdlr.requireChannelOp(
writer, request,
sessionID, clientID, nick, channel, chID,
) {
return 0, 0, false
}
}
targetSID, nickErr := hdlr.params.Database.
GetSessionByNick(ctx, targetNick)
if nickErr != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoSuchNick, nick,
[]string{targetNick},
"No such nick/channel",
)
return 0, 0, false
}
alreadyIn, aiErr := hdlr.params.Database.
IsChannelMember(ctx, chID, targetSID)
if aiErr == nil && alreadyIn {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUserOnChannel, nick,
[]string{targetNick, channel},
"is already on channel",
)
return 0, 0, false
}
return chID, targetSID, true
}
// executeInvite records the invite and sends
// notifications.
func (hdlr *Handlers) executeInvite(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, targetNick, channel string,
chID, targetSID int64,
) {
ctx := request.Context()
invErr := hdlr.params.Database.AddChannelInvite(
ctx, chID, targetSID, nick,
)
if invErr != nil {
hdlr.log.Error(
"add invite failed", "error", invErr,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return return
} }
@@ -3039,7 +2786,7 @@ func (hdlr *Handlers) executeInvite(
if mErr == nil { if mErr == nil {
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdInvite, nick, targetNick, request, irc.CmdInvite, nick, targetNick,
json.RawMessage(invBody), targetSID, 0, json.RawMessage(invBody),
[]int64{targetSID}, []int64{targetSID},
) )
} }
@@ -3534,16 +3281,13 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
return return
} }
if !hdlr.canAccessHistory( toSessionID, toChannelID, ok := hdlr.canAccessHistory(
writer, request, sessionID, nick, target, writer, request, sessionID, nick, target,
) { )
if !ok {
return return
} }
beforeID, _ := strconv.ParseInt(
request.URL.Query().Get("before"), 10, 64,
)
limit, _ := strconv.Atoi( limit, _ := strconv.Atoi(
request.URL.Query().Get("limit"), request.URL.Query().Get("limit"),
) )
@@ -3552,8 +3296,19 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
} }
msgs, err := hdlr.params.Database.GetHistory( msgs, err := hdlr.params.Database.GetHistory(
request.Context(), target, beforeID, limit, request.Context(), toSessionID, toChannelID,
request.URL.Query().Get("before"), limit,
) )
if errors.Is(err, sql.ErrNoRows) {
hdlr.respondError(
writer, request,
"unknown before id",
http.StatusBadRequest,
)
return
}
if err != nil { if err != nil {
hdlr.log.Error( hdlr.log.Error(
"get history failed", "error", err, "get history failed", "error", err,
@@ -3574,17 +3329,23 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
} }
// canAccessHistory verifies the user can read history // canAccessHistory verifies the user can read history
// for the given target (channel or DM participant). // for the given target (channel or DM participant) and
// returns the session id and channel id to pass to
// GetHistory: the user's own session for its nick, or the
// channel's id, with 0 for the other. On false it has
// written the error response.
func (hdlr *Handlers) canAccessHistory( func (hdlr *Handlers) canAccessHistory(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID int64,
nick, target string, nick, target string,
) bool { ) (int64, int64, bool) {
if strings.HasPrefix(target, "#") { if strings.HasPrefix(target, "#") {
return hdlr.canAccessChannelHistory( chID, ok := hdlr.canAccessChannelHistory(
writer, request, sessionID, target, writer, request, sessionID, target,
) )
return 0, chID, ok
} }
// DM history: only allow if the target is the // DM history: only allow if the target is the
@@ -3596,18 +3357,20 @@ func (hdlr *Handlers) canAccessHistory(
http.StatusForbidden, http.StatusForbidden,
) )
return false return 0, 0, false
} }
return true return sessionID, 0, true
} }
// canAccessChannelHistory returns the id of the channel
// named target if the user is a member of it.
func (hdlr *Handlers) canAccessChannelHistory( func (hdlr *Handlers) canAccessChannelHistory(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID int64,
target string, target string,
) bool { ) (int64, bool) {
chID, err := hdlr.params.Database.GetChannelByName( chID, err := hdlr.params.Database.GetChannelByName(
request.Context(), target, request.Context(), target,
) )
@@ -3618,7 +3381,7 @@ func (hdlr *Handlers) canAccessChannelHistory(
http.StatusNotFound, http.StatusNotFound,
) )
return false return 0, false
} }
isMember, err := hdlr.params.Database.IsChannelMember( isMember, err := hdlr.params.Database.IsChannelMember(
@@ -3634,7 +3397,7 @@ func (hdlr *Handlers) canAccessChannelHistory(
http.StatusInternalServerError, http.StatusInternalServerError,
) )
return false return 0, false
} }
if !isMember { if !isMember {
@@ -3644,10 +3407,10 @@ func (hdlr *Handlers) canAccessChannelHistory(
http.StatusForbidden, http.StatusForbidden,
) )
return false return 0, false
} }
return true return chID, true
} }
// HandleLogout deletes the authenticated client's token // HandleLogout deletes the authenticated client's token
@@ -3663,15 +3426,11 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
return return
} }
ctx := request.Context() err := hdlr.svc.Logout(
request.Context(), sessionID, clientID, nick,
err := hdlr.params.Database.DeleteClient(
ctx, clientID,
) )
if err != nil { if err != nil {
hdlr.log.Error( hdlr.log.Error("logout failed", "error", err)
"delete client failed", "error", err,
)
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
"internal error", "internal error",
@@ -3681,23 +3440,6 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
return return
} }
// If no clients remain, clean up the user fully:
// part all channels (notifying members) and
// delete the session.
remaining, err := hdlr.params.Database.
ClientCountForSession(ctx, sessionID)
if err != nil {
hdlr.log.Error(
"client count check failed", "error", err,
)
}
if remaining == 0 {
hdlr.cleanupUser(
ctx, sessionID, nick,
)
}
hdlr.clearAuthCookie(writer) hdlr.clearAuthCookie(writer)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
@@ -3903,7 +3645,7 @@ func (hdlr *Handlers) handleKick(
} }
err := hdlr.svc.KickUser( err := hdlr.svc.KickUser(
request.Context(), sessionID, nick, request.Context(), sessionID, 0, nick,
channel, targetNick, reason, channel, targetNick, reason,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -3956,17 +3698,19 @@ func (hdlr *Handlers) deliverWhoisIdle(
} }
// fanOut inserts a message and enqueues it to all given // fanOut inserts a message and enqueues it to all given
// sessions. // sessions. toSessionID and toChannelID are the recipient
// InsertMessage records for history, 0 when there is none.
func (hdlr *Handlers) fanOut( func (hdlr *Handlers) fanOut(
request *http.Request, request *http.Request,
command, from, target string, command, from, target string,
toSessionID, toChannelID int64,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage, meta json.RawMessage,
sessionIDs []int64, sessionIDs []int64,
) (string, error) { ) (string, error) {
dbID, msgUUID, err := hdlr.params.Database.InsertMessage( dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
request.Context(), command, from, target, request.Context(), command, from, target,
nil, body, meta, toSessionID, toChannelID, nil, body, meta,
) )
if err != nil { if err != nil {
return "", fmt.Errorf("insert message: %w", err) return "", fmt.Errorf("insert message: %w", err)
@@ -3974,7 +3718,7 @@ func (hdlr *Handlers) fanOut(
for _, sid := range sessionIDs { for _, sid := range sessionIDs {
enqErr := hdlr.params.Database.EnqueueToSession( enqErr := hdlr.params.Database.EnqueueToSession(
request.Context(), sid, dbID, request.Context(), sid, dbID, 0,
) )
if enqErr != nil { if enqErr != nil {
hdlr.log.Error("enqueue failed", hdlr.log.Error("enqueue failed",
@@ -3991,12 +3735,13 @@ func (hdlr *Handlers) fanOut(
func (hdlr *Handlers) fanOutSilent( func (hdlr *Handlers) fanOutSilent(
request *http.Request, request *http.Request,
command, from, target string, command, from, target string,
toSessionID, toChannelID int64,
body json.RawMessage, body json.RawMessage,
sessionIDs []int64, sessionIDs []int64,
) error { ) error {
_, err := hdlr.fanOut( _, err := hdlr.fanOut(
request, command, from, target, request, command, from, target,
body, nil, sessionIDs, toSessionID, toChannelID, body, nil, sessionIDs,
) )
return err return err
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -8,7 +8,12 @@ import (
"sneak.berlin/go/neoirc/pkg/irc" "sneak.berlin/go/neoirc/pkg/irc"
) )
const minPasswordLength = 8 // A session password is 8 to 72 bytes; bcrypt cannot hash
// a longer one.
const (
minPasswordLength = 8
maxPasswordLength = 72
)
// HandleLogin authenticates a user with nick and password. // HandleLogin authenticates a user with nick and password.
func (hdlr *Handlers) HandleLogin() http.HandlerFunc { func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
@@ -149,12 +154,13 @@ func (hdlr *Handlers) handlePass(
password := lines[0] password := lines[0]
if len(password) < minPasswordLength { if len(password) < minPasswordLength ||
len(password) > maxPasswordLength {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, clientID, sessionID, writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick, irc.ErrNeedMoreParams, nick,
[]string{irc.CmdPass}, []string{irc.CmdPass},
"Password must be at least 8 characters", "Password must be 8 to 72 bytes",
) )
return return
+19
View File
@@ -0,0 +1,19 @@
package handlers
import (
"context"
"sneak.berlin/go/neoirc/internal/db"
)
// SetPollMessages makes HandleGetMessages read the client
// output queue with poll in place of Database.PollMessages.
func (hdlr *Handlers) SetPollMessages(
poll func(
ctx context.Context,
clientID, afterQueueID int64,
limit int,
) ([]db.IRCMessage, int64, error),
) {
hdlr.pollMessages = poll
}
+28 -19
View File
@@ -55,16 +55,25 @@ const spentHashcashTTL = 365 * 24 * time.Hour
// Handlers manages HTTP request handling. // Handlers manages HTTP request handling.
type Handlers struct { type Handlers struct {
params *Params params *Params
log *slog.Logger log *slog.Logger
hc *healthcheck.Healthcheck hc *healthcheck.Healthcheck
broker *broker.Broker broker *broker.Broker
svc *service.Service svc *service.Service
hashcashVal *hashcash.Validator hashcashVal *hashcash.Validator
channelHashcash *hashcash.ChannelValidator loginLimiter *ratelimit.Limiter
loginLimiter *ratelimit.Limiter stats *stats.Tracker
stats *stats.Tracker cancelCleanup context.CancelFunc
cancelCleanup context.CancelFunc
// pollMessages is Database.PollMessages, with which
// HandleGetMessages reads the client output queue. Tests
// replace it to act just after a read, or to make a read
// fail.
pollMessages func(
ctx context.Context,
clientID, afterQueueID int64,
limit int,
) ([]db.IRCMessage, int64, error)
} }
// New creates a new Handlers instance. // New creates a new Handlers instance.
@@ -88,15 +97,15 @@ func New(
} }
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
params: &params, params: &params,
log: params.Logger.Get(), log: params.Logger.Get(),
hc: params.Healthcheck, hc: params.Healthcheck,
broker: params.Broker, broker: params.Broker,
svc: params.Service, svc: params.Service,
hashcashVal: hashcash.NewValidator(resource), hashcashVal: hashcash.NewValidator(resource),
channelHashcash: hashcash.NewChannelValidator(), loginLimiter: ratelimit.New(loginRate, loginBurst),
loginLimiter: ratelimit.New(loginRate, loginBurst), stats: params.Stats,
stats: params.Stats, pollMessages: params.Database.PollMessages,
} }
lifecycle.Append(fx.Hook{ lifecycle.Append(fx.Hook{
+304
View File
@@ -0,0 +1,304 @@
package handlers
import (
"net/http"
"strings"
"time"
"sneak.berlin/go/neoirc/pkg/irc"
)
// dispatchOperCommand routes the server operator commands
// OPER, KILL and WALLOPS to their handlers.
func (hdlr *Handlers) dispatchOperCommand(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, command string,
bodyLines func() []string,
) {
switch command {
case irc.CmdOper:
hdlr.handleOper(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdKill:
hdlr.handleKill(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdWallops:
hdlr.handleWallops(
writer, request,
sessionID, clientID, nick, bodyLines,
)
}
}
// handleUserhost handles USERHOST: user@host for each of
// up to five nicks given in the body.
func (hdlr *Handlers) handleUserhost(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
lines := bodyLines()
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdUserhost},
"Not enough parameters",
)
return
}
reply, err := hdlr.svc.UserhostReply(
request.Context(), lines, hdlr.serverName(),
)
if hdlr.handleServiceError(
writer, request, clientID, sessionID, nick, err,
) {
return
}
hdlr.enqueueNumeric(
request.Context(), clientID,
irc.RplUserHost, nick, nil, reply,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"},
http.StatusOK)
}
// handleVersion handles VERSION.
func (hdlr *Handlers) handleVersion(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
hdlr.enqueueNumeric(
request.Context(), clientID, irc.RplVersion, nick,
[]string{
hdlr.svc.ServerVersion() + ".",
hdlr.serverName(),
},
"",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"},
http.StatusOK)
}
// handleAdmin handles ADMIN.
func (hdlr *Handlers) handleAdmin(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
location1, location2, email := hdlr.svc.AdminLines(srvName)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminMe, nick,
[]string{srvName}, "Administrative info",
)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc1, nick, nil, location1,
)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc2, nick, nil, location2,
)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminEmail, nick, nil, email,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"},
http.StatusOK)
}
// handleInfo handles INFO.
func (hdlr *Handlers) handleInfo(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
for _, line := range hdlr.svc.InfoLines() {
hdlr.enqueueNumeric(
ctx, clientID, irc.RplInfo, nick, nil, line,
)
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfInfo, nick, nil,
"End of /INFO list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"},
http.StatusOK)
}
// handleTime handles TIME: the server's local time.
func (hdlr *Handlers) handleTime(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
hdlr.enqueueNumeric(
request.Context(), clientID, irc.RplTime, nick,
[]string{hdlr.serverName()},
time.Now().Format(time.RFC1123),
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"},
http.StatusOK)
}
// handleKill handles KILL: the body is the target nick and
// an optional reason.
func (hdlr *Handlers) handleKill(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
lines := bodyLines()
targetNick := ""
if len(lines) > 0 {
targetNick = strings.TrimSpace(lines[0])
}
if targetNick == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdKill},
"Not enough parameters",
)
return
}
reason := ""
if len(lines) > 1 {
reason = lines[1]
}
err := hdlr.svc.KillUser(
request.Context(), sessionID, nick,
targetNick, reason,
)
if hdlr.handleServiceError(
writer, request, clientID, sessionID, nick, err,
) {
return
}
hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"},
http.StatusOK)
}
// handleWallops handles WALLOPS: the body is the message.
func (hdlr *Handlers) handleWallops(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
lines := bodyLines()
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdWallops},
"Not enough parameters",
)
return
}
err := hdlr.svc.SendWallops(
request.Context(), sessionID, nick,
strings.Join(lines, " "),
)
if hdlr.handleServiceError(
writer, request, clientID, sessionID, nick, err,
) {
return
}
hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"},
http.StatusOK)
}
// handleUserMode handles MODE for a nick: a query when the
// body is empty, otherwise a change by the mode string in
// the body. Only the caller's own nick is allowed.
func (hdlr *Handlers) handleUserMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, target string,
bodyLines func() []string,
) {
if target != nick {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUsersDoNotMatch, nick, nil,
"Can't change mode for other users",
)
return
}
ctx := request.Context()
var (
modes string
err error
)
lines := bodyLines()
if len(lines) > 0 {
modes, err = hdlr.svc.ApplyUserMode(
ctx, sessionID, lines[0],
)
} else {
modes, err = hdlr.svc.QueryUserMode(ctx, sessionID)
}
if hdlr.handleServiceError(
writer, request, clientID, sessionID, nick, err,
) {
return
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUmodeIs, nick, nil, modes,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"},
http.StatusOK)
}
+337
View File
@@ -0,0 +1,337 @@
//nolint:paralleltest // global viper, as in api_test.go
package handlers_test
import (
"net/http"
"strings"
"testing"
"sneak.berlin/go/neoirc/pkg/irc"
)
// Each test starts one server and reuses it for all its
// checks, and makes as few requests as it can: servers,
// sessions and requests are what makes this package's tests
// slow.
// sendAndPoll sends cmd from the session token and returns
// the messages after lastID that the session has been sent,
// and the ID of the last of them.
func sendAndPoll(
tserver *testServer,
token string,
lastID int64,
cmd map[string]any,
) ([]map[string]any, int64) {
tserver.t.Helper()
tserver.sendCommand(token, cmd)
return tserver.pollMessages(token, lastID)
}
// newOperSession creates a session for nick, makes it a
// server operator, and returns its token and the ID of the
// last message it has been sent.
func newOperSession(
tserver *testServer, nick string,
) (string, int64) {
tserver.t.Helper()
token := tserver.createSession(nick)
_, lastID := sendAndPoll(tserver, token, 0, map[string]any{
commandKey: irc.CmdOper,
bodyKey: []string{testOperName, testOperPassword},
})
return token, lastID
}
// numericBody returns the first body line of the message in
// msgs with the given numeric, and fails the test if there
// is none.
func numericBody(
t *testing.T, msgs []map[string]any, numeric string,
) string {
t.Helper()
msg := findNumericWithParams(msgs, numeric)
if msg == nil {
t.Fatalf("expected numeric %s, got %v", numeric, msgs)
}
lines, _ := msg[bodyKey].([]any)
if len(lines) == 0 {
return ""
}
line, _ := lines[0].(string)
return line
}
func TestUserhost(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("alice")
tserver.createSession("bob")
_, lastID := tserver.pollMessages(token, 0)
msgs, lastID := sendAndPoll(tserver, token, lastID, map[string]any{
commandKey: irc.CmdUserhost,
bodyKey: []string{"alice", "bob"},
})
body := numericBody(t, msgs, "302")
if !strings.HasPrefix(body, "alice=+alice@") ||
!strings.Contains(body, " bob=+bob@") {
t.Errorf("expected alice and bob, got %q", body)
}
msgs, lastID = sendAndPoll(tserver, token, lastID, map[string]any{
commandKey: irc.CmdUserhost,
bodyKey: []string{"ghost"},
})
body = numericBody(t, msgs, "302")
if body != "" {
t.Errorf("expected an empty reply for ghost, got %q", body)
}
msgs, _ = sendAndPoll(tserver, token, lastID, map[string]any{
commandKey: irc.CmdUserhost,
})
if !findNumeric(msgs, "461") {
t.Errorf("expected ERR_NEEDMOREPARAMS (461), got %v", msgs)
}
}
func TestVersionAdminInfoTime(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("frank")
_, lastID := tserver.pollMessages(token, 0)
msgs, lastID := sendAndPoll(tserver, token, lastID, map[string]any{
commandKey: irc.CmdVersion,
})
params := getNumericParams(findNumericWithParams(msgs, "351"))
if len(params) == 0 || params[0] != "neoirc-test-test." {
t.Errorf("expected RPL_VERSION neoirc-test-test., got %v", msgs)
}
for _, check := range []struct {
command string
numerics []string
}{
{irc.CmdAdmin, []string{"256", "257", "258", "259"}},
{irc.CmdInfo, []string{"371", "374"}},
{irc.CmdTime, []string{"391"}},
} {
msgs, lastID = sendAndPoll(tserver, token, lastID, map[string]any{
commandKey: check.command,
})
for _, numeric := range check.numerics {
if !findNumeric(msgs, numeric) {
t.Errorf(
"%s: expected %s, got %v",
check.command, numeric, msgs,
)
}
}
}
}
func TestKill(t *testing.T) {
tserver := newTestServerWithOper(t)
victimToken := tserver.createSession("victim")
observerToken := tserver.createSession("observer")
notOperToken := tserver.createSession("notoper")
operToken, operLastID := newOperSession(tserver, "killer")
for _, token := range []string{victimToken, observerToken} {
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd,
toKey: "#killtest",
})
}
_, notOperLastID := tserver.pollMessages(notOperToken, 0)
msgs, _ := sendAndPoll(tserver, notOperToken, notOperLastID, map[string]any{
commandKey: irc.CmdKill,
bodyKey: []string{"victim"},
})
if !findNumeric(msgs, "481") {
t.Errorf("expected ERR_NOPRIVILEGES (481), got %v", msgs)
}
for _, check := range []struct {
numeric string
body []string
}{
{"461", []string{}},
{"401", []string{"ghost"}},
{"483", []string{"killer"}},
} {
msgs, operLastID = sendAndPoll(tserver, operToken, operLastID, map[string]any{
commandKey: irc.CmdKill,
bodyKey: check.body,
})
if !findNumeric(msgs, check.numeric) {
t.Errorf(
"KILL %v: expected %s, got %v",
check.body, check.numeric, msgs,
)
}
}
_, observerLastID := tserver.pollMessages(observerToken, 0)
status, result := tserver.sendCommand(operToken, map[string]any{
commandKey: irc.CmdKill,
bodyKey: []string{"victim", "go away"},
})
if status != http.StatusOK || result[statusKey] != "ok" {
t.Fatalf("expected 200 and status ok, got %d: %v", status, result)
}
msgs, _ = tserver.pollMessages(observerToken, observerLastID)
if !findMessage(msgs, irc.CmdQuit, "victim") {
t.Errorf("expected the observer to see QUIT, got %v", msgs)
}
status, _ = tserver.getState(victimToken)
if status != http.StatusUnauthorized {
t.Errorf("expected the victim's session gone, got %d", status)
}
}
func TestWallops(t *testing.T) {
tserver := newTestServerWithOper(t)
receiverToken := tserver.createSession("receiver")
plainToken := tserver.createSession("plain")
operToken, operLastID := newOperSession(tserver, "walloper")
_, receiverLastID := sendAndPoll(tserver, receiverToken, 0, map[string]any{
commandKey: modeCmd,
toKey: "receiver",
bodyKey: []string{"+w"},
})
_, plainLastID := tserver.pollMessages(plainToken, 0)
msgs, plainLastID := sendAndPoll(tserver, plainToken, plainLastID, map[string]any{
commandKey: irc.CmdWallops,
bodyKey: []string{"not allowed"},
})
if !findNumeric(msgs, "481") {
t.Errorf("expected ERR_NOPRIVILEGES (481), got %v", msgs)
}
msgs, _ = sendAndPoll(tserver, operToken, operLastID, map[string]any{
commandKey: irc.CmdWallops,
})
if !findNumeric(msgs, "461") {
t.Errorf("expected ERR_NEEDMOREPARAMS (461), got %v", msgs)
}
tserver.sendCommand(operToken, map[string]any{
commandKey: irc.CmdWallops,
bodyKey: []string{"server going down"},
})
msgs, _ = tserver.pollMessages(receiverToken, receiverLastID)
if !findMessage(msgs, irc.CmdWallops, "walloper") {
t.Errorf("expected WALLOPS for the +w user, got %v", msgs)
}
msgs, _ = tserver.pollMessages(plainToken, plainLastID)
if findMessage(msgs, irc.CmdWallops, "walloper") {
t.Errorf("WALLOPS reached a user without +w: %v", msgs)
}
}
func TestUserMode(t *testing.T) {
const (
nick = "alice"
other = "other"
)
tserver := newTestServerWithOper(t)
token := tserver.createSession(nick)
otherToken := tserver.createSession(other)
operToken, operLastID := newOperSession(tserver, "deoper")
// mode sends MODE for target from token, with modeStr
// unless it is empty, and returns the session's next
// messages.
mode := func(
token string, lastID int64, target, modeStr string,
) ([]map[string]any, int64) {
cmd := map[string]any{commandKey: modeCmd, toKey: target}
if modeStr != "" {
cmd[bodyKey] = []string{modeStr}
}
return sendAndPoll(tserver, token, lastID, cmd)
}
_, otherLastID := mode(otherToken, 0, other, "+w")
_, lastID := tserver.pollMessages(token, 0)
for _, check := range []struct {
target, modeStr, numeric, modes string
}{
{nick, "+w", "221", "+w"},
{"Alice", "", "502", ""},
{"Alice", "-w", "502", ""},
{nick, "", "221", "+w"},
{nick, "-w", "221", "+"},
{nick, "+z", "501", ""},
{nick, "+o", "501", ""},
{other, "", "502", ""},
{other, "-w", "502", ""},
} {
var msgs []map[string]any
msgs, lastID = mode(token, lastID, check.target, check.modeStr)
if check.numeric != "221" {
if !findNumeric(msgs, check.numeric) ||
findNumeric(msgs, "221") {
t.Errorf(
"MODE %s %s: expected only %s, got %v",
check.target, check.modeStr,
check.numeric, msgs,
)
}
continue
}
got := numericBody(t, msgs, "221")
if got != check.modes {
t.Errorf(
"MODE %s %s: expected %q, got %q",
check.target, check.modeStr, check.modes, got,
)
}
}
msgs, _ := mode(otherToken, otherLastID, other, "")
if got := numericBody(t, msgs, "221"); got != "+w" {
t.Errorf("%s's modes changed to %q", other, got)
}
msgs, _ = mode(operToken, operLastID, "deoper", "-o")
if got := numericBody(t, msgs, "221"); got != "+" {
t.Errorf("after -o: expected +, got %q", got)
}
}
+53 -9
View File
@@ -77,9 +77,13 @@ func NewValidator(resource string) *Validator {
return validator return validator
} }
// Validate checks a hashcash stamp. It returns nil if the // Check returns nil if requiredBits is not positive, or if
// stamp is valid and has not been seen before. // the stamp is a well-formed, recently dated proof of work
func (v *Validator) Validate( // for this resource with at least requiredBits. It does not
// look at or change the set of spent stamps, so a request
// can check its stamp first and Claim it after its other
// checks.
func (v *Validator) Check(
stamp string, stamp string,
requiredBits int, requiredBits int,
) error { ) error {
@@ -87,9 +91,49 @@ func (v *Validator) Validate(
return nil return nil
} }
_, err := v.check(stamp, requiredBits)
return err
}
// Claim checks the stamp as Check does and, when
// requiredBits is positive, records it as spent until it
// expires. It returns an error if the stamp is already
// spent. Release gives a claimed stamp back.
func (v *Validator) Claim(
stamp string,
requiredBits int,
) error {
if requiredBits <= 0 {
return nil
}
stampTime, err := v.check(stamp, requiredBits)
if err != nil {
return err
}
return v.checkAndRecordStamp(stamp, stampTime)
}
// Release forgets a claimed stamp so that it can be claimed
// again, for a request that failed after its Claim.
// Releasing a stamp that is not claimed does nothing.
func (v *Validator) Release(stamp string) {
v.mu.Lock()
defer v.mu.Unlock()
delete(v.spent, stamp)
}
// check returns the stamp's date if Check accepts it.
func (v *Validator) check(
stamp string,
requiredBits int,
) (time.Time, error) {
parts := strings.Split(stamp, ":") parts := strings.Split(stamp, ":")
if len(parts) != stampFields { if len(parts) != stampFields {
return fmt.Errorf( return time.Time{}, fmt.Errorf(
"%w: expected %d, got %d", "%w: expected %d, got %d",
errInvalidFields, stampFields, len(parts), errInvalidFields, stampFields, len(parts),
) )
@@ -104,25 +148,25 @@ func (v *Validator) Validate(
version, bitsStr, resource, requiredBits, version, bitsStr, resource, requiredBits,
) )
if err != nil { if err != nil {
return err return time.Time{}, err
} }
stampTime, err := parseStampDate(dateStr) stampTime, err := parseStampDate(dateStr)
if err != nil { if err != nil {
return err return time.Time{}, err
} }
err = validateTime(stampTime) err = validateTime(stampTime)
if err != nil { if err != nil {
return err return time.Time{}, err
} }
err = validateProof(stamp, requiredBits) err = validateProof(stamp, requiredBits)
if err != nil { if err != nil {
return err return time.Time{}, err
} }
return v.checkAndRecordStamp(stamp, stampTime) return stampTime, nil
} }
func (v *Validator) validateHeader( func (v *Validator) validateHeader(
+76 -14
View File
@@ -85,7 +85,7 @@ func TestMintAndValidate(t *testing.T) {
t, testBits, "test-resource", todayDate(), t, testBits, "test-resource", todayDate(),
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("valid stamp rejected: %v", err) t.Fatalf("valid stamp rejected: %v", err)
} }
@@ -99,17 +99,74 @@ func TestReplayDetection(t *testing.T) {
t, testBits, "test-resource", todayDate(), t, testBits, "test-resource", todayDate(),
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("first use failed: %v", err) t.Fatalf("first use failed: %v", err)
} }
err = validator.Validate(stamp, testBits) err = validator.Claim(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("replay not detected") t.Fatal("replay not detected")
} }
} }
// TestCheckDoesNotSpendStamp checks that a stamp Check
// accepts can still be claimed, and only once, and that
// Check still accepts it once it is spent.
func TestCheckDoesNotSpendStamp(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := mintStampWithDate(
t, testBits, "test-resource", todayDate(),
)
for range 2 {
err := validator.Check(stamp, testBits)
if err != nil {
t.Fatalf("valid stamp rejected: %v", err)
}
}
err := validator.Claim(stamp, testBits)
if err != nil {
t.Fatalf("claim after check failed: %v", err)
}
err = validator.Claim(stamp, testBits)
if err == nil {
t.Fatal("replay not detected")
}
err = validator.Check(stamp, testBits)
if err != nil {
t.Fatalf("spent stamp rejected by Check: %v", err)
}
}
// TestReleasedStampCanBeClaimedAgain checks that Release
// gives a claimed stamp back.
func TestReleasedStampCanBeClaimedAgain(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := mintStampWithDate(
t, testBits, "test-resource", todayDate(),
)
err := validator.Claim(stamp, testBits)
if err != nil {
t.Fatalf("first claim failed: %v", err)
}
validator.Release(stamp)
err = validator.Claim(stamp, testBits)
if err != nil {
t.Fatalf("claim after release failed: %v", err)
}
}
func TestResourceMismatch(t *testing.T) { func TestResourceMismatch(t *testing.T) {
t.Parallel() t.Parallel()
@@ -118,7 +175,7 @@ func TestResourceMismatch(t *testing.T) {
t, testBits, "wrong-resource", todayDate(), t, testBits, "wrong-resource", todayDate(),
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected resource mismatch error") t.Fatal("expected resource mismatch error")
} }
@@ -129,7 +186,7 @@ func TestInvalidStampFormat(t *testing.T) {
validator := hashcash.NewValidator("test-resource") validator := hashcash.NewValidator("test-resource")
err := validator.Validate( err := validator.Claim(
"not:a:valid:stamp", testBits, "not:a:valid:stamp", testBits,
) )
if err == nil { if err == nil {
@@ -146,7 +203,7 @@ func TestBadVersion(t *testing.T) {
testBits, todayDate(), "test-resource", testBits, todayDate(), "test-resource",
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected bad version error") t.Fatal("expected bad version error")
} }
@@ -162,7 +219,7 @@ func TestInsufficientDifficulty(t *testing.T) {
todayDate(), "test-resource", todayDate(), "test-resource",
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected insufficient bits error") t.Fatal("expected insufficient bits error")
} }
@@ -178,7 +235,7 @@ func TestExpiredStamp(t *testing.T) {
t, testBits, "test-resource", oldDate, t, testBits, "test-resource", oldDate,
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected expired stamp error") t.Fatal("expected expired stamp error")
} }
@@ -189,9 +246,14 @@ func TestZeroBitsSkipsValidation(t *testing.T) {
validator := hashcash.NewValidator("test-resource") validator := hashcash.NewValidator("test-resource")
err := validator.Validate("garbage", 0) err := validator.Check("garbage", 0)
if err != nil { if err != nil {
t.Fatalf("zero bits should skip: %v", err) t.Fatalf("zero bits should skip the check: %v", err)
}
err = validator.Claim("garbage", 0)
if err != nil {
t.Fatalf("zero bits should skip the claim: %v", err)
} }
} }
@@ -204,7 +266,7 @@ func TestLongDateFormat(t *testing.T) {
t, testBits, "test-resource", longDate, t, testBits, "test-resource", longDate,
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("long date stamp rejected: %v", err) t.Fatalf("long date stamp rejected: %v", err)
} }
@@ -219,7 +281,7 @@ func TestBadDateFormat(t *testing.T) {
testBits, "test-resource", testBits, "test-resource",
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected bad date error") t.Fatal("expected bad date error")
} }
@@ -235,7 +297,7 @@ func TestMultipleUniqueStamps(t *testing.T) {
t, testBits, "test-resource", todayDate(), t, testBits, "test-resource", todayDate(),
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("unique stamp rejected: %v", err) t.Fatalf("unique stamp rejected: %v", err)
} }
@@ -251,7 +313,7 @@ func TestHigherBitsStillValid(t *testing.T) {
t, 4, "test-resource", todayDate(), t, 4, "test-resource", todayDate(),
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf( t.Fatalf(
"higher-difficulty stamp rejected: %v", "higher-difficulty stamp rejected: %v",
+201 -108
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -13,7 +14,7 @@ import (
) )
// sendIRCError maps a service.IRCError to an IRC numeric // sendIRCError maps a service.IRCError to an IRC numeric
// reply on the wire. // reply on the wire, and logs any other error.
func (c *Conn) sendIRCError(err error) { func (c *Conn) sendIRCError(err error) {
var ircErr *service.IRCError var ircErr *service.IRCError
if errors.As(err, &ircErr) { if errors.As(err, &ircErr) {
@@ -21,7 +22,11 @@ func (c *Conn) sendIRCError(err error) {
args = append(args, ircErr.Params...) args = append(args, ircErr.Params...)
args = append(args, ircErr.Message) args = append(args, ircErr.Message)
c.sendNumeric(ircErr.Code, args...) c.sendNumeric(ircErr.Code, args...)
return
} }
c.log.Error("command failed", "error", err)
} }
// handleCAP silently acknowledges CAP negotiation. // handleCAP silently acknowledges CAP negotiation.
@@ -66,15 +71,20 @@ func (c *Conn) handleNick(
} }
newNick := msg.Params[0] newNick := msg.Params[0]
if len(newNick) > maxNickLen { if !service.ValidNick(newNick) {
newNick = newNick[:maxNickLen] c.sendNumeric(
irc.ErrErroneusNickname,
newNick, "Erroneous nickname",
)
return
} }
oldMask := c.hostmask() oldMask := c.hostmask()
oldNick := c.nick oldNick := c.currentNick()
err := c.svc.ChangeNick( err := c.svc.ChangeNick(
ctx, c.sessionID, oldNick, newNick, ctx, c.sessionID, c.clientID, oldNick, newNick,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -82,9 +92,10 @@ func (c *Conn) handleNick(
return return
} }
c.mu.Lock() // Take the nick the session holds, not newNick: another
c.nick = newNick // client of the session may have changed it again since
c.mu.Unlock() // ChangeNick stored newNick.
c.loadSessionNick(ctx)
// Echo NICK change to the client on wire. // Echo NICK change to the client on wire.
c.send(FormatMessage(oldMask, "NICK", newNick)) c.send(FormatMessage(oldMask, "NICK", newNick))
@@ -118,8 +129,8 @@ func (c *Conn) handlePrivmsg(
body, _ := json.Marshal([]string{text}) //nolint:errchkjson body, _ := json.Marshal([]string{text}) //nolint:errchkjson
if strings.HasPrefix(target, "#") { if strings.HasPrefix(target, "#") {
_, _, err := c.svc.SendChannelMessage( _, err := c.svc.SendChannelMessage(
ctx, c.sessionID, c.nick, ctx, c.sessionID, c.clientID, c.currentNick(),
msg.Command, target, body, nil, msg.Command, target, body, nil,
) )
if err != nil { if err != nil {
@@ -127,7 +138,7 @@ func (c *Conn) handlePrivmsg(
} }
} else { } else {
result, err := c.svc.SendDirectMessage( result, err := c.svc.SendDirectMessage(
ctx, c.sessionID, c.nick, ctx, c.sessionID, c.clientID, c.currentNick(),
msg.Command, target, body, nil, msg.Command, target, body, nil,
) )
if err != nil { if err != nil {
@@ -145,7 +156,9 @@ func (c *Conn) handlePrivmsg(
} }
// handleJoin joins one or more channels via the shared // handleJoin joins one or more channels via the shared
// service layer. // service layer. The optional second parameter holds the
// channel keys, comma-separated in the order of the
// channels.
func (c *Conn) handleJoin( func (c *Conn) handleJoin(
ctx context.Context, ctx context.Context,
msg *Message, msg *Message,
@@ -159,14 +172,24 @@ func (c *Conn) handleJoin(
return return
} }
for chanName := range strings.SplitSeq(msg.Params[0], ",") { var keys []string
if len(msg.Params) > 1 {
keys = strings.Split(msg.Params[1], ",")
}
for idx, chanName := range strings.Split(msg.Params[0], ",") {
chanName = strings.TrimSpace(chanName) chanName = strings.TrimSpace(chanName)
if !strings.HasPrefix(chanName, "#") { if !strings.HasPrefix(chanName, "#") {
chanName = "#" + chanName chanName = "#" + chanName
} }
c.joinChannel(ctx, chanName) key := ""
if idx < len(keys) {
key = keys[idx]
}
c.joinChannel(ctx, chanName, key)
} }
} }
@@ -174,10 +197,10 @@ func (c *Conn) handleJoin(
// and delivers topic/names on the wire. // and delivers topic/names on the wire.
func (c *Conn) joinChannel( func (c *Conn) joinChannel(
ctx context.Context, ctx context.Context,
channel string, channel, key string,
) { ) {
result, err := c.svc.JoinChannel( result, err := c.svc.JoinChannel(
ctx, c.sessionID, c.nick, channel, "", ctx, c.sessionID, c.clientID, c.currentNick(), channel, key,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -315,7 +338,7 @@ func (c *Conn) partChannel(
channel, reason string, channel, reason string,
) { ) {
err := c.svc.PartChannel( err := c.svc.PartChannel(
ctx, c.sessionID, c.nick, channel, reason, ctx, c.sessionID, c.clientID, c.currentNick(), channel, reason,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -345,7 +368,10 @@ func (c *Conn) handleQuit(msg *Message) {
c.send("ERROR :Closing Link: " + c.hostname + c.send("ERROR :Closing Link: " + c.hostname +
" (Quit: " + reason + ")") " (Quit: " + reason + ")")
c.mu.Lock()
c.closed = true c.closed = true
c.mu.Unlock()
} }
// handleTopic gets or sets a channel topic via the shared // handleTopic gets or sets a channel topic via the shared
@@ -376,7 +402,7 @@ func (c *Conn) handleTopic(
newTopic := msg.Params[1] newTopic := msg.Params[1]
err := c.svc.SetTopic( err := c.svc.SetTopic(
ctx, c.sessionID, c.nick, channel, newTopic, ctx, c.sessionID, c.clientID, c.currentNick(), channel, newTopic,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -427,7 +453,7 @@ func (c *Conn) handleMode(
if strings.HasPrefix(target, "#") { if strings.HasPrefix(target, "#") {
c.handleChannelMode(ctx, msg) c.handleChannelMode(ctx, msg)
} else { } else {
c.handleUserMode(msg) c.handleUserMode(ctx, msg)
} }
} }
@@ -676,21 +702,26 @@ func (c *Conn) applyChannelModes(
if applied.Len() > 0 { if applied.Len() > 0 {
modeReply := applied.String() + appliedArgs.String() modeReply := applied.String() + appliedArgs.String()
c.send(FormatMessage( params := slices.Concat(
c.hostmask(), "MODE", channel, modeReply, []string{channel}, strings.Fields(modeReply),
)) )
c.send(FormatMessage(c.hostmask(), "MODE", params...))
c.svc.BroadcastMode( c.svc.BroadcastMode(
ctx, c.nick, channel, chID, modeReply, ctx, c.clientID, c.currentNick(), channel, chID, modeReply,
) )
} }
} }
// handleUserMode handles MODE for users. // handleUserMode handles MODE for a nick: a query without a
func (c *Conn) handleUserMode(msg *Message) { // mode string, otherwise a change. Only the client's own
target := msg.Params[0] // nick is allowed.
func (c *Conn) handleUserMode(
if !strings.EqualFold(target, c.nick) { ctx context.Context,
msg *Message,
) {
if msg.Params[0] != c.currentNick() {
c.sendNumeric( c.sendNumeric(
irc.ErrUsersDoNotMatch, irc.ErrUsersDoNotMatch,
"Can't change mode for other users", "Can't change mode for other users",
@@ -699,8 +730,26 @@ func (c *Conn) handleUserMode(msg *Message) {
return return
} }
// We don't support user modes beyond the basics. var (
c.sendNumeric(irc.RplUmodeIs, "+") modes string
err error
)
if len(msg.Params) == 1 {
modes, err = c.svc.QueryUserMode(ctx, c.sessionID)
} else {
modes, err = c.svc.ApplyUserMode(
ctx, c.sessionID, msg.Params[1],
)
}
if err != nil {
c.sendIRCError(err)
return
}
c.sendNumeric(irc.RplUmodeIs, modes)
} }
// handleNames replies with channel member list. // handleNames replies with channel member list.
@@ -732,10 +781,11 @@ func (c *Conn) handleNames(
c.deliverNames(ctx, channel, chID) c.deliverNames(ctx, channel, chID)
} }
// handleList sends the channel list. // handleList sends the channel list, leaving out +s
// channels the user is not on.
func (c *Conn) handleList(ctx context.Context) { func (c *Conn) handleList(ctx context.Context) {
channels, err := c.database.ListAllChannelsWithCounts( channels, err := c.database.ListAllChannelsWithCountsFiltered(
ctx, ctx, c.sessionID,
) )
if err != nil { if err != nil {
c.sendNumeric( c.sendNumeric(
@@ -855,14 +905,15 @@ func (c *Conn) whoisOper(
} }
} }
// whoisChannels sends 319 RPL_WHOISCHANNELS. // whoisChannels sends 319 RPL_WHOISCHANNELS, leaving out
// +s channels the querier is not on.
func (c *Conn) whoisChannels( func (c *Conn) whoisChannels(
ctx context.Context, ctx context.Context,
target string, target string,
targetID int64, targetID int64,
) { ) {
userChannels, _ := c.database.GetSessionChannels( userChannels, _ := c.database.GetSessionChannelsFiltered(
ctx, targetID, ctx, targetID, c.sessionID,
) )
if len(userChannels) == 0 { if len(userChannels) == 0 {
return return
@@ -1135,7 +1186,7 @@ func (c *Conn) handleKick(
} }
err := c.svc.KickUser( err := c.svc.KickUser(
ctx, c.sessionID, c.nick, ctx, c.sessionID, c.clientID, c.currentNick(),
channel, targetNick, reason, channel, targetNick, reason,
) )
if err != nil { if err != nil {
@@ -1166,17 +1217,16 @@ func (c *Conn) handlePassPostReg(
} }
password := msg.Params[0] password := msg.Params[0]
if len(password) < minPasswordLen { if !c.checkPasswordLength(password) {
c.sendFromServer("NOTICE", c.nick,
"Password must be at least 8 characters",
)
return return
} }
c.setPassword(ctx, password) err := c.setPassword(ctx, password)
if err != nil {
return
}
c.sendFromServer("NOTICE", c.nick, c.sendFromServer("NOTICE", c.currentNick(),
"Password set. You can reconnect using "+ "Password set. You can reconnect using "+
"PASS <password> with your nick.", "PASS <password> with your nick.",
) )
@@ -1199,36 +1249,11 @@ func (c *Conn) handleInvite(
targetNick := msg.Params[0] targetNick := msg.Params[0]
channel := msg.Params[1] channel := msg.Params[1]
chID, err := c.database.GetChannelByName(ctx, channel) targetID, err := c.svc.InviteUser(
if err != nil { ctx, c.sessionID, c.currentNick(), targetNick, channel,
c.sendNumeric(
irc.ErrNoSuchChannel,
channel, "No such channel",
)
return
}
isMember, _ := c.database.IsChannelMember(
ctx, chID, c.sessionID,
)
if !isMember {
c.sendNumeric(
irc.ErrNotOnChannel,
channel, "You're not on that channel",
)
return
}
targetID, err := c.database.GetSessionByNick(
ctx, targetNick,
) )
if err != nil { if err != nil {
c.sendNumeric( c.sendIRCError(err)
irc.ErrNoSuchNick,
targetNick, "No such nick",
)
return return
} }
@@ -1237,58 +1262,126 @@ func (c *Conn) handleInvite(
irc.RplInviting, targetNick, channel, irc.RplInviting, targetNick, channel,
) )
// Send INVITE notice to target via service fan-out. // Queue the INVITE to the target with the body the HTTP
body, _ := json.Marshal( //nolint:errchkjson // API uses.
[]string{"You have been invited to " + channel}, body, _ := json.Marshal([]string{channel}) //nolint:errchkjson
)
_, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, "INVITE", c.nick, targetNick, ctx, "INVITE", c.currentNick(), targetNick, targetID, 0,
nil, body, nil, []int64{targetID}, nil, body, nil, []int64{targetID}, 0,
) )
} }
// handleUserhost replies with USERHOST info. // handleUserhost replies with user@host for each of up to
// five nicks.
func (c *Conn) handleUserhost( func (c *Conn) handleUserhost(
ctx context.Context, ctx context.Context,
msg *Message, msg *Message,
) { ) {
if len(msg.Params) < 1 { if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
irc.CmdUserhost, "Not enough parameters",
)
return return
} }
replies := make([]string, 0, len(msg.Params)) reply, err := c.svc.UserhostReply(
ctx, msg.Params, c.serverSfx,
)
if err != nil {
c.sendIRCError(err)
for _, nick := range msg.Params { return
sid, err := c.database.GetSessionByNick(ctx, nick)
if err != nil {
continue
}
hostInfo, _ := c.database.GetSessionHostInfo(
ctx, sid,
)
host := "*"
if hostInfo != nil {
host = hostInfo.Hostname
}
isOper, _ := c.database.IsSessionOper(ctx, sid)
operStar := ""
if isOper {
operStar = "*"
}
replies = append(
replies,
nick+operStar+"=+"+nick+"@"+host,
)
} }
c.sendNumeric(irc.RplUserHost, reply)
}
// handleVersion replies with the server version.
func (c *Conn) handleVersion() {
c.sendNumeric( c.sendNumeric(
irc.RplUserHost, irc.RplVersion,
strings.Join(replies, " "), c.svc.ServerVersion()+".", c.serverSfx, "",
) )
} }
// handleAdmin replies with the server's admin info.
func (c *Conn) handleAdmin() {
location1, location2, email := c.svc.AdminLines(c.serverSfx)
c.sendNumeric(
irc.RplAdminMe, c.serverSfx, "Administrative info",
)
c.sendNumeric(irc.RplAdminLoc1, location1)
c.sendNumeric(irc.RplAdminLoc2, location2)
c.sendNumeric(irc.RplAdminEmail, email)
}
// handleInfo replies with the server's software info.
func (c *Conn) handleInfo() {
for _, line := range c.svc.InfoLines() {
c.sendNumeric(irc.RplInfo, line)
}
c.sendNumeric(irc.RplEndOfInfo, "End of /INFO list")
}
// handleTime replies with the server's local time.
func (c *Conn) handleTime() {
c.sendNumeric(
irc.RplTime,
c.serverSfx, time.Now().Format(time.RFC1123),
)
}
// handleKill handles KILL <nick> [<reason>].
func (c *Conn) handleKill(
ctx context.Context,
msg *Message,
) {
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
irc.CmdKill, "Not enough parameters",
)
return
}
reason := ""
if len(msg.Params) > 1 {
reason = msg.Params[1]
}
err := c.svc.KillUser(
ctx, c.sessionID, c.currentNick(),
msg.Params[0], reason,
)
if err != nil {
c.sendIRCError(err)
}
}
// handleWallops handles WALLOPS <message>.
func (c *Conn) handleWallops(
ctx context.Context,
msg *Message,
) {
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
irc.CmdWallops, "Not enough parameters",
)
return
}
err := c.svc.SendWallops(
ctx, c.sessionID, c.currentNick(), msg.Params[0],
)
if err != nil {
c.sendIRCError(err)
}
}
+223 -27
View File
@@ -20,14 +20,14 @@ import (
const ( const (
maxLineLen = 512 maxLineLen = 512
readTimeout = 5 * time.Minute
writeTimeout = 30 * time.Second writeTimeout = 30 * time.Second
killWriteWindow = 2 * time.Second
dnsTimeout = 3 * time.Second dnsTimeout = 3 * time.Second
pollInterval = 100 * time.Millisecond pollInterval = 100 * time.Millisecond
pingInterval = 90 * time.Second pingInterval = 90 * time.Second
pongDeadline = 30 * time.Second pongDeadline = 30 * time.Second
maxNickLen = 32
minPasswordLen = 8 minPasswordLen = 8
maxPasswordLen = 72 // bcrypt cannot hash a longer one
maxHashcashBits = 40 maxHashcashBits = 40
) )
@@ -46,6 +46,11 @@ type Conn struct {
serverSfx string serverSfx string
commands map[string]cmdHandler commands map[string]cmdHandler
// writeMu serializes writes to conn, which come from
// serve(), from the relay goroutine and, for KILL, from
// Disconnect.
writeMu sync.Mutex
mu sync.Mutex mu sync.Mutex
nick string nick string
username string username string
@@ -58,14 +63,15 @@ type Conn struct {
registered bool registered bool
gotNick bool gotNick bool
gotUser bool gotUser bool
gotPass bool
passWord string passWord string
lastQueueID int64 lastQueueID int64
closed bool closed bool
killed bool
} }
func newConn( func newConn(
ctx context.Context,
tcpConn net.Conn, tcpConn net.Conn,
log *slog.Logger, log *slog.Logger,
database *db.Database, database *db.Database,
@@ -89,7 +95,6 @@ func newConn(
svc: svc, svc: svc,
serverSfx: srvName, serverSfx: srvName,
remoteIP: host, remoteIP: host,
hostname: resolveHost(ctx, host),
} }
conn.commands = conn.buildCommandMap() conn.commands = conn.buildCommandMap()
@@ -97,6 +102,57 @@ func newConn(
return conn return conn
} }
// Disconnect ends the connection for an operator KILL from
// either transport: the victim is sent KILL and ERROR, then
// its socket is closed, which ends serve() and with it the
// relay goroutine. It is called from the killer's goroutine
// and does not wait for the writes, so a victim that has
// stopped reading cannot stall the killer.
func (c *Conn) Disconnect(reason string) {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return
}
c.closed = true
c.killed = true
nick := c.nick
host := c.hostname
c.mu.Unlock()
if nick == "" {
nick = "*"
}
go func() {
c.notifyKilledAndClose(nick, host, reason)
}()
}
// notifyKilledAndClose sends a killed victim the KILL and
// ERROR lines, each bounded by killWriteWindow, and then
// closes its socket.
func (c *Conn) notifyKilledAndClose(
nick, host, reason string,
) {
defer func() { _ = c.conn.Close() }()
c.sendWithin(
killWriteWindow,
FormatMessage(
c.serverSfx, irc.CmdKill, nick, reason,
),
)
c.sendWithin(
killWriteWindow,
"ERROR :Closing Link: "+host+
" ("+reason+")",
)
}
// buildCommandMap returns a map from IRC command strings // buildCommandMap returns a map from IRC command strings
// to handler functions. // to handler functions.
func (c *Conn) buildCommandMap() map[string]cmdHandler { func (c *Conn) buildCommandMap() map[string]cmdHandler {
@@ -129,7 +185,13 @@ func (c *Conn) buildCommandMap() map[string]cmdHandler {
"CAP": func(_ context.Context, msg *Message) { "CAP": func(_ context.Context, msg *Message) {
c.handleCAP(msg) c.handleCAP(msg)
}, },
"USERHOST": c.handleUserhost, irc.CmdUserhost: c.handleUserhost,
irc.CmdVersion: func(context.Context, *Message) { c.handleVersion() },
irc.CmdAdmin: func(context.Context, *Message) { c.handleAdmin() },
irc.CmdInfo: func(context.Context, *Message) { c.handleInfo() },
irc.CmdTime: func(context.Context, *Message) { c.handleTime() },
irc.CmdKill: c.handleKill,
irc.CmdWallops: c.handleWallops,
} }
} }
@@ -150,18 +212,42 @@ func resolveHost(ctx context.Context, addr string) string {
} }
// serve is the main loop for a single IRC client connection. // serve is the main loop for a single IRC client connection.
// It first sets the client's hostname to what resolve returns
// for the client's address.
// Cancelling ctx when it returns stops the relay goroutine. // Cancelling ctx when it returns stops the relay goroutine.
func (c *Conn) serve(ctx context.Context) { // When nothing has been read for interval the client is sent
// a PING, and when nothing is read within deadline after that
// the connection is closed.
func (c *Conn) serve(
ctx context.Context,
resolve func(ctx context.Context, addr string) string,
interval, deadline time.Duration,
) {
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
defer cancel() defer cancel()
defer c.cleanup(ctx) // Stopping the server cancels ctx, and the cleanup must
// still end the session then.
defer c.cleanup(context.WithoutCancel(ctx))
// The hostname is looked up here, on this connection's
// goroutine, and not when the connection is accepted, so
// that a slow lookup holds up only this client.
c.hostname = resolve(ctx, c.remoteIP)
ping := time.AfterFunc(interval, func() {
c.send("PING :" + c.serverSfx)
})
defer ping.Stop()
scanner := bufio.NewScanner(c.conn) scanner := bufio.NewScanner(c.conn)
scanner.Buffer(make([]byte, maxLineLen), maxLineLen) scanner.Buffer(make([]byte, maxLineLen), maxLineLen)
for { for {
// Every line read starts both waits again.
ping.Reset(interval)
_ = c.conn.SetReadDeadline( _ = c.conn.SetReadDeadline(
time.Now().Add(readTimeout), time.Now().Add(interval + deadline),
) )
if !scanner.Scan() { if !scanner.Scan() {
@@ -180,7 +266,7 @@ func (c *Conn) serve(ctx context.Context) {
c.handleMessage(ctx, msg) c.handleMessage(ctx, msg)
if c.closed { if c.isClosed() {
return return
} }
} }
@@ -189,24 +275,82 @@ func (c *Conn) serve(ctx context.Context) {
func (c *Conn) cleanup(ctx context.Context) { func (c *Conn) cleanup(ctx context.Context) {
c.mu.Lock() c.mu.Lock()
wasRegistered := c.registered wasRegistered := c.registered
wasKilled := c.killed
sessID := c.sessionID sessID := c.sessionID
nick := c.nick nick := c.nick
c.closed = true c.closed = true
c.mu.Unlock() c.mu.Unlock()
if wasRegistered && sessID > 0 { if wasRegistered && sessID > 0 {
c.svc.BroadcastQuit( c.svc.UnregisterWireConn(sessID, c)
ctx, sessID, nick, "Connection closed",
) // KILL has already sent the QUIT and deleted the
// session.
if !wasKilled {
c.svc.BroadcastQuit(
ctx, sessID, nick, "Connection closed",
)
}
} }
c.conn.Close() //nolint:errcheck,gosec c.conn.Close() //nolint:errcheck,gosec
} }
// isClosed reports whether QUIT or KILL has ended the
// connection.
func (c *Conn) isClosed() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.closed
}
// currentNick returns c.nick, read under c.mu.
func (c *Conn) currentNick() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.nick
}
// loadSessionNick sets c.nick to the nick the connection's
// session holds in the database, read by session ID. The
// connection's own NICK and the relay passing on a NICK line
// both call it, and it reads and sets the nick under c.mu, so
// whichever runs last leaves the connection on its session's
// nick. If the read fails, c.nick is left as it is.
func (c *Conn) loadSessionNick(ctx context.Context) {
c.mu.Lock()
defer c.mu.Unlock()
nick, err := c.database.GetSessionNick(ctx, c.sessionID)
if err == nil {
c.nick = nick
}
}
// send writes a formatted IRC line to the connection. // send writes a formatted IRC line to the connection.
func (c *Conn) send(line string) { func (c *Conn) send(line string) {
c.sendWithin(writeTimeout, line)
}
// sendWithin writes a formatted IRC line to the connection,
// giving up after timeout. CR, LF and NUL are removed first,
// as FormatMessage does for each parameter, so that a line
// built without it cannot end early either.
func (c *Conn) sendWithin(
timeout time.Duration,
line string,
) {
line = strings.ReplaceAll(line, "\r", "")
line = strings.ReplaceAll(line, "\n", "")
line = strings.ReplaceAll(line, "\x00", "")
c.writeMu.Lock()
defer c.writeMu.Unlock()
_ = c.conn.SetWriteDeadline( _ = c.conn.SetWriteDeadline(
time.Now().Add(writeTimeout), time.Now().Add(timeout),
) )
_, _ = fmt.Fprintf(c.conn, "%s\r\n", line) _, _ = fmt.Fprintf(c.conn, "%s\r\n", line)
@@ -217,7 +361,7 @@ func (c *Conn) sendNumeric(
code irc.IRCMessageType, code irc.IRCMessageType,
params ...string, params ...string,
) { ) {
nick := c.nick nick := c.currentNick()
if nick == "" { if nick == "" {
nick = "*" nick = "*"
} }
@@ -241,9 +385,11 @@ func (c *Conn) sendFromServer(
// hostmask returns the client's full hostmask // hostmask returns the client's full hostmask
// (nick!user@host). // (nick!user@host).
func (c *Conn) hostmask() string { func (c *Conn) hostmask() string {
nick := c.currentNick()
user := c.username user := c.username
if user == "" { if user == "" {
user = c.nick user = nick
} }
host := c.hostname host := c.hostname
@@ -251,7 +397,7 @@ func (c *Conn) hostmask() string {
host = c.remoteIP host = c.remoteIP
} }
return c.nick + "!" + user + "@" + host return nick + "!" + user + "@" + host
} }
// handleMessage dispatches a parsed IRC message using // handleMessage dispatches a parsed IRC message using
@@ -261,13 +407,24 @@ func (c *Conn) handleMessage(
msg *Message, msg *Message,
) { ) {
// Before registration, only NICK, USER, PASS, PING, // Before registration, only NICK, USER, PASS, PING,
// QUIT, and CAP are accepted. // PONG, QUIT, and CAP are accepted.
if !c.registered { if !c.registered {
c.handlePreRegistration(ctx, msg) c.handlePreRegistration(ctx, msg)
return return
} }
// Every command is activity, so the idle cleanup must not
// remove this user.
err := c.database.UpdateLastSeen(
ctx, c.sessionID, c.clientID,
)
if err != nil {
c.log.Error(
"failed to update last_seen", "error", err,
)
}
handler, ok := c.commands[msg.Command] handler, ok := c.commands[msg.Command]
if !ok { if !ok {
c.sendNumeric( c.sendNumeric(
@@ -299,6 +456,7 @@ func (c *Conn) handlePreRegistration(
} }
c.passWord = msg.Params[0] c.passWord = msg.Params[0]
c.gotPass = true
case irc.CmdNick: case irc.CmdNick:
if len(msg.Params) < 1 { if len(msg.Params) < 1 {
c.sendNumeric( c.sendNumeric(
@@ -309,11 +467,16 @@ func (c *Conn) handlePreRegistration(
return return
} }
c.nick = msg.Params[0] if !service.ValidNick(msg.Params[0]) {
if len(c.nick) > maxNickLen { c.sendNumeric(
c.nick = c.nick[:maxNickLen] irc.ErrErroneusNickname,
msg.Params[0], "Erroneous nickname",
)
return
} }
c.nick = msg.Params[0]
c.gotNick = true c.gotNick = true
case irc.CmdUser: case irc.CmdUser:
if len(msg.Params) < 4 { //nolint:mnd if len(msg.Params) < 4 { //nolint:mnd
@@ -331,6 +494,10 @@ func (c *Conn) handlePreRegistration(
case irc.CmdPing: case irc.CmdPing:
c.handlePing(msg) c.handlePing(msg)
return
case irc.CmdPong:
// The answer to serve's PING, which unregistered
// clients are sent too.
return return
case irc.CmdQuit: case irc.CmdQuit:
c.handleQuit(msg) c.handleQuit(msg)
@@ -387,7 +554,10 @@ func (c *Conn) completeRegistration(ctx context.Context) {
"failed to create session", "error", err, "failed to create session", "error", err,
) )
c.send("ERROR :Internal server error") c.send("ERROR :Internal server error")
c.mu.Lock()
c.closed = true c.closed = true
c.mu.Unlock()
return return
} }
@@ -398,17 +568,23 @@ func (c *Conn) completeRegistration(ctx context.Context) {
c.registered = true c.registered = true
c.mu.Unlock() c.mu.Unlock()
// If PASS was provided before registration, set the // So that KILL, from either transport, can close it.
// session password. c.svc.RegisterWireConn(sessionID, c)
if c.passWord != "" && len(c.passWord) >= minPasswordLen {
c.setPassword(ctx, c.passWord)
}
// Send welcome burst. // Send welcome burst.
c.deliverWelcome() c.deliverWelcome()
c.deliverLusers(ctx) c.deliverLusers(ctx)
c.deliverMOTD() c.deliverMOTD()
// If PASS was given before registration, store its
// password now, after the welcome burst, so that a
// NOTICE refusing it reaches a registered client. A
// failure to store it is only logged, as it is for PASS
// after registration.
if c.gotPass && c.checkPasswordLength(c.passWord) {
_ = c.setPassword(ctx, c.passWord)
}
// Start the message relay goroutine. // Start the message relay goroutine.
go c.relayMessages(ctx) go c.relayMessages(ctx)
} }
@@ -491,13 +667,33 @@ func (c *Conn) deliverMOTD() {
) )
} }
// setPassword sets a bcrypt password on the session. // checkPasswordLength reports whether pw is 8 to 72 bytes
func (c *Conn) setPassword(ctx context.Context, pw string) { // long, and sends the client a NOTICE saying so when it is
// not.
func (c *Conn) checkPasswordLength(pw string) bool {
if len(pw) >= minPasswordLen && len(pw) <= maxPasswordLen {
return true
}
c.sendFromServer("NOTICE", c.currentNick(),
"Password must be 8 to 72 bytes",
)
return false
}
// setPassword sets a bcrypt password on the session, and
// logs and returns the error when it was not stored.
func (c *Conn) setPassword(ctx context.Context, pw string) error {
// Use the database's auth module to hash and store. // Use the database's auth module to hash and store.
err := c.database.SetPassword(ctx, c.sessionID, pw) err := c.database.SetPassword(ctx, c.sessionID, pw)
if err != nil { if err != nil {
c.log.Error( c.log.Error(
"failed to set password", "error", err, "failed to set password", "error", err,
) )
return err
} }
return nil
} }
+148
View File
@@ -0,0 +1,148 @@
package ircserver_test
import (
"bufio"
"log/slog"
"net"
"os"
"slices"
"strings"
"testing"
"time"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/ircserver"
)
// newKillVictim returns a Conn for the nick victim whose
// socket is one end of a net.Pipe, and the other end, from
// which the test reads what the victim is sent. A write to
// the pipe blocks until the other end reads.
func newKillVictim(t *testing.T) (*ircserver.Conn, net.Conn) {
t.Helper()
serverSide, clientSide := net.Pipe()
t.Cleanup(func() { _ = clientSide.Close() })
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelError},
))
cfg := &config.Config{ServerName: testServerName}
return ircserver.NewTestConn(
log, cfg, serverSide, "victim",
), clientSide
}
// TestDisconnectDoesNotBlockOnUnresponsiveVictim checks
// that Disconnect returns although the victim never reads,
// so a KILL cannot stall the operator who sent it. The
// limit is well below one KillWriteWindow, so a Disconnect
// that waits on any write to the victim fails.
func TestDisconnectDoesNotBlockOnUnresponsiveVictim(
t *testing.T,
) {
t.Parallel()
victim, _ := newKillVictim(t)
returned := make(chan struct{})
go func() {
victim.Disconnect("killed by oper")
close(returned)
}()
select {
case <-returned:
case <-time.After(ircserver.KillWriteWindow / 4):
t.Fatal("Disconnect waited for the victim to read")
}
}
// TestDisconnectNotifiesAndClosesVictim checks that a
// victim that reads is sent KILL and ERROR, and that its
// connection is then closed.
func TestDisconnectNotifiesAndClosesVictim(t *testing.T) {
t.Parallel()
victim, clientSide := newKillVictim(t)
received := make(chan []string, 1)
go func() {
var lines []string
scanner := bufio.NewScanner(clientSide)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
received <- lines
}()
victim.Disconnect("killed by oper")
var lines []string
select {
case lines = <-received:
case <-time.After(testTimeout):
t.Fatal("the victim's connection was not closed")
}
joined := strings.Join(lines, "\n")
if !strings.Contains(joined, "KILL victim :killed by oper") {
t.Errorf("missing KILL line, got: %q", joined)
}
if !strings.Contains(joined, "ERROR :Closing Link:") {
t.Errorf("missing ERROR line, got: %q", joined)
}
}
// TestDisconnectReasonCannotStartLine checks that CR, LF and
// NUL in a KILL reason are removed from both the KILL line
// and the ERROR line, so the victim gets exactly those two
// lines. The ERROR line is not built with FormatMessage, so
// only the removal in sendWithin protects it.
func TestDisconnectReasonCannotStartLine(t *testing.T) {
t.Parallel()
victim, clientSide := newKillVictim(t)
received := make(chan []string, 1)
go func() {
var lines []string
scanner := bufio.NewScanner(clientSide)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
received <- lines
}()
victim.Disconnect("bye\r\nPRIVMSG victim :forged\x00")
var lines []string
select {
case lines = <-received:
case <-time.After(testTimeout):
t.Fatal("the victim's connection was not closed")
}
// The test connection has no host name, hence the two
// spaces after "Closing Link:".
want := []string{
":" + testServerName +
" KILL victim :byePRIVMSG victim :forged",
"ERROR :Closing Link: (byePRIVMSG victim :forged)",
}
if !slices.Equal(lines, want) {
t.Errorf("got lines %q, want %q", lines, want)
}
}
+71 -2
View File
@@ -4,23 +4,36 @@ import (
"context" "context"
"log/slog" "log/slog"
"net" "net"
"time"
"sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/service"
) )
// NewTestServer creates a Server suitable for testing. // NewTestServer creates a Server suitable for testing, whose
// connections use interval and deadline in place of
// pingInterval and pongDeadline, and resolve in place of
// resolveHost.
// The caller must call Stop() when finished. // The caller must call Stop() when finished.
func NewTestServer( func NewTestServer(
log *slog.Logger, log *slog.Logger,
cfg *config.Config, cfg *config.Config,
database *db.Database, database *db.Database,
brk *broker.Broker, brk *broker.Broker,
interval, deadline time.Duration,
resolve func(ctx context.Context, addr string) string,
) *Server { ) *Server {
globs := &globals.Globals{
Appname: "neoirc",
Version: "test",
StartTime: time.Now(),
}
svc := service.NewTestService( svc := service.NewTestService(
database, brk, cfg, log, database, brk, cfg, globs, log,
) )
return &Server{ //nolint:exhaustruct return &Server{ //nolint:exhaustruct
@@ -30,9 +43,19 @@ func NewTestServer(
brk: brk, brk: brk,
svc: svc, svc: svc,
conns: make(map[*Conn]struct{}), conns: make(map[*Conn]struct{}),
pingInterval: interval,
pongDeadline: deadline,
resolveHost: resolve,
} }
} }
// ResolveHost is the reverse DNS lookup that a server made by
// New does for each client.
func ResolveHost(ctx context.Context, addr string) string {
return resolveHost(ctx, addr)
}
// Start exposes the unexported start method for tests. // Start exposes the unexported start method for tests.
func (s *Server) Start(addr string) error { func (s *Server) Start(addr string) error {
return s.start(context.Background(), addr) return s.start(context.Background(), addr)
@@ -47,3 +70,49 @@ func (s *Server) Stop() {
func (s *Server) Listener() net.Listener { func (s *Server) Listener() net.Listener {
return s.listener return s.listener
} }
// Service returns the service the server's connections use.
func (s *Server) Service() *service.Service {
return s.svc
}
// PingTimes returns how long the server's connections wait
// with nothing read before they are sent a PING, and after it
// before they are closed.
func (s *Server) PingTimes() (time.Duration, time.Duration) {
return s.pingInterval, s.pongDeadline
}
// KillWriteWindow is how long Disconnect allows each write
// to a killed victim.
const KillWriteWindow = killWriteWindow
// PollInterval is how often the relay checks the client
// output queue when no broker notification arrives.
const PollInterval = pollInterval
// PingInterval is how long the server waits with nothing read
// from a connection before it sends a PING.
const PingInterval = pingInterval
// PongDeadline is how long the server waits after the PING
// for a line before it closes the connection.
const PongDeadline = pongDeadline
// StopTimeout is the longest Stop waits for the connections'
// cleanup.
const StopTimeout = stopTimeout
// NewTestConn returns a Conn for nick on tcpConn, without a
// server, database or service behind it.
func NewTestConn(
log *slog.Logger,
cfg *config.Config,
tcpConn net.Conn,
nick string,
) *Conn {
conn := newConn(tcpConn, log, nil, nil, cfg, nil)
conn.nick = nick
return conn
}
+642
View File
@@ -4,6 +4,8 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"sneak.berlin/go/neoirc/internal/config"
) )
// TestIntegrationTwoClients is a comprehensive integration // TestIntegrationTwoClients is a comprehensive integration
@@ -762,6 +764,373 @@ func TestIntegrationTwoClients(t *testing.T) {
) )
} }
// ── Tier 3 Utility Command Integration Tests ──────────
// TestIntegrationUserhost verifies the USERHOST command
// returns user@host info for connected nicks.
func TestIntegrationUserhost(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
bob.send("AWAY :lunch")
bob.readUntil(func(l string) bool {
return strings.Contains(l, " 306 ")
})
for _, step := range []struct {
line, want string
}{
{"USERHOST alice", " 302 alice alice=+alice@"},
{"USERHOST alice bob", " :alice=+alice@"},
{"USERHOST alice bob", " bob=-bob@"},
{"USERHOST nobody", " 302 alice :"},
{"USERHOST", " 461 alice USERHOST :"},
} {
alice.send(step.line)
reply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 302 ") ||
strings.Contains(l, " 461 ")
})
assertContains(t, reply, step.want, step.line)
}
}
// TestIntegrationVersion verifies the VERSION command
// returns the server version string.
func TestIntegrationVersion(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.send("VERSION")
aliceReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 351 ")
})
assertContains(
t, aliceReply, " 351 ",
"RPL_VERSION",
)
assertContains(
t, aliceReply, "neoirc",
"VERSION reply contains server name",
)
}
// TestIntegrationAdmin verifies the ADMIN command returns
// server admin info (256–259 numerics).
func TestIntegrationAdmin(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.send("ADMIN")
aliceReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 259 ")
})
assertContains(
t, aliceReply, " 256 ",
"RPL_ADMINME",
)
assertContains(
t, aliceReply, " 257 ",
"RPL_ADMINLOC1",
)
assertContains(
t, aliceReply, " 258 ",
"RPL_ADMINLOC2",
)
assertContains(
t, aliceReply, " 259 ",
"RPL_ADMINEMAIL",
)
}
// TestIntegrationInfo verifies the INFO command returns
// server information (371/374 numerics).
func TestIntegrationInfo(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.send("INFO")
aliceReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 374 ")
})
assertContains(
t, aliceReply, " 371 ",
"RPL_INFO",
)
assertContains(
t, aliceReply, " 374 ",
"RPL_ENDOFINFO",
)
assertContains(
t, aliceReply, "neoirc",
"INFO reply mentions server name",
)
}
// TestIntegrationTime verifies the TIME command returns
// the server time (391 numeric).
func TestIntegrationTime(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.send("TIME")
aliceReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 391 ")
})
assertContains(
t, aliceReply, " 391 ",
"RPL_TIME",
)
assertContains(
t, aliceReply, testServerName,
"TIME reply includes server name",
)
}
// becomeOper sends OPER with newTestEnvWithOper's
// credentials and waits for RPL_YOUREOPER.
func (tc *testClient) becomeOper() {
tc.t.Helper()
tc.send("OPER testoper testpass")
tc.readUntil(func(l string) bool {
return strings.Contains(l, " 381 ")
})
}
// TestIntegrationKillRefused covers the KILL errors: not an
// operator, no such nick, and killing yourself.
func TestIntegrationKillRefused(t *testing.T) {
t.Parallel()
env := newTestEnvWithOper(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
for _, step := range []struct {
line, numeric string
}{
{"KILL bob :nope", " 481 "},
{"OPER testoper testpass", " 381 "},
{"KILL nobody123 :gone", " 401 "},
{"KILL alice :me", " 483 "},
} {
alice.send(step.line)
reply := alice.readUntil(func(l string) bool {
return strings.Contains(l, step.numeric)
})
assertContains(t, reply, step.numeric, step.line)
}
}
// TestIntegrationKill checks that the victim of a KILL is
// told why, is disconnected, and is gone from its channels.
func TestIntegrationKill(t *testing.T) {
t.Parallel()
env := newTestEnvWithOper(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
alice.joinAndDrain("#killtest")
bob.joinAndDrain("#killtest")
// Drain alice's view of bob's join.
alice.readUntil(func(l string) bool {
return strings.Contains(l, "JOIN") &&
strings.Contains(l, "bob")
})
alice.becomeOper()
alice.send("KILL bob :bad behavior")
bobLines := bob.readUntilClosed()
assertContains(
t, bobLines, "KILL",
"victim receives KILL before disconnect",
)
assertContains(
t, bobLines, "ERROR :Closing Link",
"victim receives ERROR before disconnect",
)
assertContains(
t, bobLines, "bad behavior",
"KILL reason delivered to victim",
)
// alice should see bob's QUIT relay.
aliceSeesQuit := alice.readUntil(func(l string) bool {
return strings.Contains(l, "QUIT") &&
strings.Contains(l, "bob")
})
assertContains(
t, aliceSeesQuit, "Killed",
"KILL reason in QUIT message",
)
// bob must be gone from the channel member list.
alice.send("NAMES #killtest")
aliceNames := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 366 ")
})
assertContains(
t, aliceNames, "alice",
"alice still in NAMES after killing bob",
)
assertNotContains(
t, aliceNames, "bob",
"killed user must not appear in NAMES",
)
// ...nor from WHO.
alice.send("WHO #killtest")
aliceWho := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 315 ")
})
assertNotContains(
t, aliceWho, "bob",
"killed user must not appear in WHO",
)
}
// TestIntegrationWallops verifies the WALLOPS command:
// oper can broadcast to +w users.
func TestIntegrationWallops(t *testing.T) {
t.Parallel()
env := newTestEnvWithOper(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
// Non-oper WALLOPS should fail.
alice.send("WALLOPS :test broadcast")
aliceWallopsFail := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 481 ")
})
assertContains(
t, aliceWallopsFail, " 481 ",
"ERR_NOPRIVILEGES for non-oper WALLOPS",
)
alice.becomeOper()
// bob sets +w to receive wallops.
bob.send("MODE bob +w")
bob.readUntil(func(l string) bool {
return strings.Contains(l, " 221 ")
})
// alice sends WALLOPS.
alice.send("WALLOPS :important announcement")
// bob (who has +w) should receive it.
bobWallops := bob.readUntil(func(l string) bool {
return strings.Contains(
l, "important announcement",
)
})
assertContains(
t, bobWallops, "important announcement",
"bob receives WALLOPS message",
)
assertContains(
t, bobWallops, "WALLOPS",
"message is WALLOPS command",
)
}
// TestIntegrationUserMode covers MODE for a nick: changes
// and queries of your own modes, refusal for another nick,
// even one that differs from yours only in letter case, and
// a rejected mode string changing nothing.
func TestIntegrationUserMode(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
const (
notYours = " 502 alice :Can't change mode for other users"
unknown = " 501 alice :Unknown MODE flag"
)
for _, step := range []struct {
line, want string
}{
{"MODE alice +w", " 221 alice +w"},
{"MODE Alice", notYours},
{"MODE Alice -w", notYours},
{"MODE bob", notYours},
{"MODE bob -w", notYours},
{"MODE alice xw", unknown},
{"MODE alice -w+z", unknown},
{"MODE alice", " 221 alice +w"},
{"MODE alice +w-w", " 221 alice +"},
} {
alice.send(step.line)
reply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 221 ") ||
strings.Contains(l, " 501 ") ||
strings.Contains(l, " 502 ")
})
last := reply[len(reply)-1]
if !strings.HasSuffix(last, step.want) {
t.Errorf("%s: want %q, got %q", step.line, step.want, last)
}
}
}
// TestIntegrationModeSecret tests +s (secret) channel // TestIntegrationModeSecret tests +s (secret) channel
// mode — verifies that +s can be set and the mode is // mode — verifies that +s can be set and the mode is
// reflected in MODE queries. // reflected in MODE queries.
@@ -872,6 +1241,252 @@ func TestIntegrationModeModerated(t *testing.T) {
) )
} }
// TestIntegrationModeKey checks that JOIN takes the key of
// a +k channel as its second parameter, and that with
// several channels each key goes to the channel in the
// same position.
func TestIntegrationModeKey(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#keyed")
alice.joinAndDrain("#vault")
// The IRC listener cannot set +k, so the test sets the
// keys directly.
for channel, key := range map[string]string{
"#keyed": "sesame",
"#vault": "barley",
} {
chID, err := env.database.GetChannelByName(
t.Context(), channel,
)
if err != nil {
t.Fatalf("get channel %s: %v", channel, err)
}
err = env.database.SetChannelKey(t.Context(), chID, key)
if err != nil {
t.Fatalf("set key of %s: %v", channel, err)
}
}
bob := env.dial(t)
bob.register("bob")
bob.sendAndExpect("JOIN #keyed", " 475 ")
bob.sendAndExpect("JOIN #keyed wrong", " 475 ")
lines := bob.sendAndExpect("JOIN #keyed sesame", " 366 ")
assertContains(
t, lines, "JOIN #keyed", "bob joins with the key",
)
carol := env.dial(t)
carol.register("carol")
lines = carol.sendAndExpect(
"JOIN #keyed,#vault sesame,barley", " 366 carol #vault ",
)
assertContains(t, lines, "JOIN #keyed", "carol joins #keyed")
assertContains(t, lines, "JOIN #vault", "carol joins #vault")
assertNotContains(t, lines, " 475 ", "carol's JOIN")
}
// TestIntegrationModeInviteOnly checks that INVITE lets a
// user into a +i channel.
func TestIntegrationModeInviteOnly(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#invonly")
alice.sendAndExpect("MODE #invonly +i", "+i")
bob := env.dial(t)
bob.register("bob")
bob.sendAndExpect("JOIN #invonly", " 473 ")
alice.sendAndExpect("INVITE bob #invonly", " 341 ")
lines := bob.sendAndExpect("JOIN #invonly", " 366 ")
assertContains(
t, lines, "JOIN #invonly", "bob joins once invited",
)
}
// TestIntegrationModeInviteOnlyNeedsOperator checks that on
// a +i channel an INVITE from a member who is not a channel
// operator gets 482 and does not let the invited user in.
func TestIntegrationModeInviteOnlyNeedsOperator(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#invonly")
// Bob joins before +i is set, so he is a member but
// not an operator.
bob := env.dial(t)
bob.register("bob")
bob.joinAndDrain("#invonly")
alice.sendAndExpect("MODE #invonly +i", "+i")
carol := env.dial(t)
carol.register("carol")
lines := bob.sendAndExpect("INVITE carol #invonly", " 482 ")
assertContains(
t, lines, " 482 bob #invonly ", "bob's INVITE refused",
)
carol.sendAndExpect("JOIN #invonly", " 473 ")
}
// TestIntegrationInviteUserOnChannel checks that inviting a
// user who is already on the channel gets 443.
func TestIntegrationInviteUserOnChannel(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#room")
bob := env.dial(t)
bob.register("bob")
bob.joinAndDrain("#room")
lines := alice.sendAndExpect("INVITE bob #room", " 443 ")
assertContains(
t, lines, " 443 alice bob #room ", "alice's INVITE refused",
)
}
// TestIntegrationInviteLine checks that an IRC listener user
// invited by an IRC listener user, and then over the HTTP
// API, gets an INVITE line from the inviter for the channel
// each time.
func TestIntegrationInviteLine(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#inv")
bob := env.dial(t)
bob.register("bob")
alice.sendAndExpect("INVITE bob #inv", " 341 ")
got := bob.readLine()
want := ":alice!alice@* INVITE bob #inv"
if got != want {
t.Errorf("from the IRC listener: got %q, want %q", got, want)
}
// The HTTP API queues an INVITE with the channel as its body.
env.queueMessage(t, "bob", "INVITE", "carol", "bob", []string{"#inv"})
got = bob.readLine()
want = ":carol!carol@* INVITE bob #inv"
if got != want {
t.Errorf("over the HTTP API: got %q, want %q", got, want)
}
}
// TestIntegrationModeSecretList checks that LIST shows a +s
// channel only to its members.
func TestIntegrationModeSecretList(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#hidden")
alice.joinAndDrain("#visible")
alice.sendAndExpect("MODE #hidden +s", "+s")
bob := env.dial(t)
bob.register("bob")
bobList := bob.sendAndExpect("LIST", " 323 ")
assertContains(
t, bobList, " 322 bob #visible ", "bob's LIST",
)
assertNotContains(t, bobList, "#hidden", "bob's LIST")
aliceList := alice.sendAndExpect("LIST", " 323 ")
assertContains(
t, aliceList, " 322 alice #hidden ", "alice's LIST",
)
}
// TestIntegrationModeSecretWhois checks that WHOIS names a
// +s channel only to users on it.
func TestIntegrationModeSecretWhois(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#hidden")
alice.joinAndDrain("#visible")
alice.sendAndExpect("MODE #hidden +s", "+s")
bob := env.dial(t)
bob.register("bob")
bobWhois := bob.sendAndExpect("WHOIS alice", " 318 ")
assertContains(t, bobWhois, "@#visible", "bob's WHOIS")
assertNotContains(t, bobWhois, "#hidden", "bob's WHOIS")
carol := env.dial(t)
carol.register("carol")
carol.joinAndDrain("#hidden")
carolWhois := carol.sendAndExpect("WHOIS alice", " 318 ")
assertContains(t, carolWhois, "@#hidden", "carol's WHOIS")
}
// TestIntegrationModeHashcash checks that PRIVMSG to a +H
// channel is refused, since an IRC client cannot attach a
// hashcash stamp.
func TestIntegrationModeHashcash(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#stamped")
alice.sendAndExpect("MODE #stamped +H 2", "+H")
lines := alice.sendAndExpect(
"PRIVMSG #stamped :hello", " 404 ",
)
assertContains(
t, lines, " 404 alice #stamped :Channel requires hashcash",
"PRIVMSG refused",
)
}
// TestIntegrationThirdClientObserver verifies that a third // TestIntegrationThirdClientObserver verifies that a third
// client observing the same channel receives messages from // client observing the same channel receives messages from
// the other two. // the other two.
@@ -915,3 +1530,30 @@ func TestIntegrationThirdClientObserver(t *testing.T) {
"carol receives trio message", "carol receives trio message",
) )
} }
// TestIntegrationDefaultServerNameFallback checks that with
// SERVER_NAME unset, as it is by default, VERSION, ADMIN and
// TIME name the server "neoirc".
func TestIntegrationDefaultServerNameFallback(t *testing.T) {
t.Parallel()
env := newTestEnvWithConfig(t, &config.Config{})
alice := env.dial(t)
alice.register("alice")
for _, step := range []struct {
line, lastNumeric, want string
}{
{"VERSION", " 351 ", " 351 alice neoirc-test. neoirc "},
{"ADMIN", " 259 ", " 256 alice neoirc "},
{"TIME", " 391 ", " 391 alice neoirc "},
} {
alice.send(step.line)
reply := alice.readUntil(func(l string) bool {
return strings.Contains(l, step.lastNumeric)
})
assertContains(t, reply, step.want, step.line)
}
}
+8 -1
View File
@@ -90,7 +90,10 @@ func ParseMessage(line string) *Message {
} }
// FormatMessage formats an IRC message into wire protocol // FormatMessage formats an IRC message into wire protocol
// format (without the trailing CR-LF). // format (without the trailing CR-LF). CR, LF and NUL are
// removed from each parameter, because on the wire they would
// end the line early; this happens before deciding whether
// the last parameter needs a leading ':'.
func FormatMessage( func FormatMessage(
prefix, command string, prefix, command string,
params ...string, params ...string,
@@ -106,6 +109,10 @@ func FormatMessage(
buf.WriteString(command) buf.WriteString(command)
for i, param := range params { for i, param := range params {
param = strings.ReplaceAll(param, "\r", "")
param = strings.ReplaceAll(param, "\n", "")
param = strings.ReplaceAll(param, "\x00", "")
buf.WriteByte(' ') buf.WriteByte(' ')
isLast := i == len(params)-1 isLast := i == len(params)-1
+71 -61
View File
@@ -3,6 +3,7 @@ package ircserver
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"slices"
"strings" "strings"
"time" "time"
@@ -26,22 +27,30 @@ func (c *Conn) relayMessages(ctx context.Context) {
default: default:
} }
// Register with the broker before draining, so that a
// message queued after the drain has read the queue
// still signals waitCh.
waitCh := c.brk.Wait(c.sessionID)
// Drain any available messages. // Drain any available messages.
delivered := c.drainQueue(ctx) delivered := c.drainQueue(ctx)
if delivered { if delivered {
c.brk.Remove(c.sessionID, waitCh)
// Tight loop while there are messages. // Tight loop while there are messages.
continue continue
} }
// Wait for notification or timeout. // Wait for notification or timeout.
waitCh := c.brk.Wait(c.sessionID)
select { select {
case <-waitCh: case <-waitCh:
// New message notification — loop back. // New message notification — loop back.
case <-ticker.C: case <-ticker.C:
// Periodic check. // Periodic check. Nothing used waitCh, so take it
// out of the broker before the next pass adds
// another.
c.brk.Remove(c.sessionID, waitCh)
case <-ctx.Done(): case <-ctx.Done():
c.brk.Remove(c.sessionID, waitCh) c.brk.Remove(c.sessionID, waitCh)
@@ -83,7 +92,7 @@ func (c *Conn) drainQueue(ctx context.Context) bool {
// //
//nolint:cyclop // dispatch table //nolint:cyclop // dispatch table
func (c *Conn) deliverIRCMessage( func (c *Conn) deliverIRCMessage(
_ context.Context, ctx context.Context,
msg *db.IRCMessage, msg *db.IRCMessage,
) { ) {
command := msg.Command command := msg.Command
@@ -105,13 +114,13 @@ func (c *Conn) deliverIRCMessage(
case isNumeric(command): case isNumeric(command):
c.deliverNumeric(msg, text) c.deliverNumeric(msg, text)
case command == irc.CmdPrivmsg || command == irc.CmdNotice: case command == irc.CmdPrivmsg || command == irc.CmdNotice:
c.deliverTextMessage(msg, command, text) c.deliverTextMessage(msg, command, bodyLines)
case command == irc.CmdJoin: case command == irc.CmdJoin:
c.deliverJoin(msg) c.deliverJoin(msg)
case command == irc.CmdPart: case command == irc.CmdPart:
c.deliverPart(msg, text) c.deliverPart(msg, text)
case command == irc.CmdNick: case command == irc.CmdNick:
c.deliverNickChange(msg, text) c.deliverNickChange(ctx, msg, text)
case command == irc.CmdQuit: case command == irc.CmdQuit:
c.deliverQuitMsg(msg, text) c.deliverQuitMsg(msg, text)
case command == irc.CmdTopic: case command == irc.CmdTopic:
@@ -120,8 +129,10 @@ func (c *Conn) deliverIRCMessage(
c.deliverKickMsg(msg, text) c.deliverKickMsg(msg, text)
case command == "INVITE": case command == "INVITE":
c.deliverInviteMsg(msg, text) c.deliverInviteMsg(msg, text)
case command == irc.CmdWallops:
c.deliverWallops(msg, text)
case command == irc.CmdMode: case command == irc.CmdMode:
c.deliverMode(msg, text) c.deliverMode(msg, bodyLines)
case command == irc.CmdPing: case command == irc.CmdPing:
// Server-originated PING — reply with PONG. // Server-originated PING — reply with PONG.
c.sendFromServer("PING", c.serverSfx) c.sendFromServer("PING", c.serverSfx)
@@ -177,43 +188,28 @@ func (c *Conn) deliverNumeric(
c.send(FormatMessage(from, msg.Command, allParams...)) c.send(FormatMessage(from, msg.Command, allParams...))
} }
// deliverTextMessage sends PRIVMSG or NOTICE. // deliverTextMessage sends PRIVMSG or NOTICE, one line for
// each line of the body.
func (c *Conn) deliverTextMessage( func (c *Conn) deliverTextMessage(
msg *db.IRCMessage, msg *db.IRCMessage,
command, text string, command string,
bodyLines []string,
) { ) {
from := msg.From from := msg.From
target := msg.To target := msg.To
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
// Don't echo our own messages back.
if strings.EqualFold(from, nick) {
return
}
prefix := from prefix := from
if !strings.Contains(prefix, "!") { if !strings.Contains(prefix, "!") {
prefix = from + "!" + from + "@*" prefix = from + "!" + from + "@*"
} }
c.send(FormatMessage(prefix, command, target, text)) for _, line := range bodyLines {
c.send(FormatMessage(prefix, command, target, line))
}
} }
// deliverJoin sends a JOIN notification. // deliverJoin sends a JOIN notification.
func (c *Conn) deliverJoin(msg *db.IRCMessage) { func (c *Conn) deliverJoin(msg *db.IRCMessage) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
// Don't echo our own JOINs (we already sent them
// during joinChannel).
if strings.EqualFold(msg.From, nick) {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
channel := msg.To channel := msg.To
@@ -222,14 +218,6 @@ func (c *Conn) deliverJoin(msg *db.IRCMessage) {
// deliverPart sends a PART notification. // deliverPart sends a PART notification.
func (c *Conn) deliverPart(msg *db.IRCMessage, text string) { func (c *Conn) deliverPart(msg *db.IRCMessage, text string) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if strings.EqualFold(msg.From, nick) {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
channel := msg.To channel := msg.To
@@ -242,18 +230,17 @@ func (c *Conn) deliverPart(msg *db.IRCMessage, text string) {
} }
} }
// deliverNickChange sends a NICK change notification. // deliverNickChange sends a NICK change notification. The
// change may be its own session's, made from another client,
// so the connection first takes the nick its session holds
// now. It does not go by the line's old nick: the connection
// may have taken that nick after another user gave it up.
func (c *Conn) deliverNickChange( func (c *Conn) deliverNickChange(
ctx context.Context,
msg *db.IRCMessage, msg *db.IRCMessage,
newNick string, newNick string,
) { ) {
c.mu.Lock() c.loadSessionNick(ctx)
nick := c.nick
c.mu.Unlock()
if strings.EqualFold(msg.From, nick) {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
@@ -265,14 +252,6 @@ func (c *Conn) deliverQuitMsg(
msg *db.IRCMessage, msg *db.IRCMessage,
text string, text string,
) { ) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if strings.EqualFold(msg.From, nick) {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
if text != "" { if text != "" {
@@ -325,27 +304,58 @@ func (c *Conn) deliverKickMsg(
} }
} }
// deliverInviteMsg sends an INVITE notification. // deliverInviteMsg sends an INVITE line from the inviter
// for the channel named in text, or nothing when text names
// no channel.
func (c *Conn) deliverInviteMsg( func (c *Conn) deliverInviteMsg(
_ *db.IRCMessage, msg *db.IRCMessage,
text string, text string,
) { ) {
if text == "" {
return
}
c.mu.Lock() c.mu.Lock()
nick := c.nick nick := c.nick
c.mu.Unlock() c.mu.Unlock()
c.sendFromServer("NOTICE", nick, text) prefix := msg.From + "!" + msg.From + "@*"
c.send(FormatMessage(prefix, "INVITE", nick, text))
} }
// deliverMode sends a MODE change notification. // deliverWallops sends a WALLOPS notification.
func (c *Conn) deliverMode( func (c *Conn) deliverWallops(
msg *db.IRCMessage, msg *db.IRCMessage,
text string, text string,
) { ) {
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
target := msg.To
if text != "" { c.send(FormatMessage(
c.send(FormatMessage(prefix, "MODE", target, text)) prefix, irc.CmdWallops, text,
))
}
// deliverMode sends a MODE change notification. The first
// body line is the change, possibly followed by its values
// separated by spaces ("+o bob"); any further lines are
// values ("+k", "secret"). Each goes out as its own
// parameter, as IRC sends them.
func (c *Conn) deliverMode(
msg *db.IRCMessage,
bodyLines []string,
) {
if len(bodyLines) == 0 || bodyLines[0] == "" {
return
} }
prefix := msg.From + "!" + msg.From + "@*"
params := slices.Concat(
[]string{msg.To},
strings.Fields(bodyLines[0]),
bodyLines[1:],
)
c.send(FormatMessage(prefix, "MODE", params...))
} }
+697
View File
@@ -0,0 +1,697 @@
package ircserver_test
import (
"context"
"encoding/json"
"slices"
"strings"
"testing"
"time"
"sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/service"
)
// queueMessage queues a message for the user with nick
// recipient the way the HTTP API sends one: stored once, then
// queued to the recipient's session.
func (env *testEnv) queueMessage(
t *testing.T,
recipient, command, from, to string,
body []string,
) {
t.Helper()
ctx := t.Context()
bodyJSON, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
msgID, _, err := env.database.InsertMessage(
ctx, command, from, to, 0, 0, nil, bodyJSON, nil,
)
if err != nil {
t.Fatalf("insert message: %v", err)
}
sessionID, err := env.database.GetSessionByNick(ctx, recipient)
if err != nil {
t.Fatalf("get session of %s: %v", recipient, err)
}
err = env.database.EnqueueToSession(ctx, sessionID, msgID, 0)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
env.brk.Notify(sessionID)
}
// TestRelayKeepsOneWaitChannel checks that an IRC listener
// connection holds at most one broker wait channel across
// several relay ticks, both while idle and after the relay
// has delivered it a message, and none once it closes.
func TestRelayKeepsOneWaitChannel(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("idle")
sessionID, err := env.database.GetSessionByNick(t.Context(), "idle")
if err != nil {
t.Fatalf("get session of idle: %v", err)
}
env.checkOneWaitChannel(t, sessionID)
env.queueMessage(
t, "idle", "PRIVMSG", "carol", "idle", []string{"hello"},
)
client.readUntil(func(l string) bool {
return strings.Contains(l, "hello")
})
env.checkOneWaitChannel(t, sessionID)
err = client.conn.Close()
if err != nil {
t.Fatalf("close: %v", err)
}
deadline := time.Now().Add(testTimeout)
for env.brk.ListenerCount(sessionID) != 0 {
if time.Now().After(deadline) {
t.Fatalf("after close: got %d wait channels, want 0",
env.brk.ListenerCount(sessionID))
}
time.Sleep(relayCheckStep)
}
}
// checkOneWaitChannel fails the test if the session
// sessionID holds more than one broker wait channel at any of
// several relay ticks.
func (env *testEnv) checkOneWaitChannel(
t *testing.T,
sessionID int64,
) {
t.Helper()
const ticks = 5
for tick := range ticks {
time.Sleep(ircserver.PollInterval)
got := env.brk.ListenerCount(sessionID)
if got > 1 {
t.Fatalf("tick %d: got %d wait channels, want at most 1",
tick+1, got)
}
}
}
// TestRelayMultiLineBody checks that each line of a PRIVMSG
// or NOTICE body sent over the HTTP API reaches an IRC
// listener user as its own line.
func TestRelayMultiLineBody(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
for _, command := range []string{"PRIVMSG", "NOTICE"} {
env.queueMessage(
t, "alice", command, "carol", "alice",
[]string{"first line", "second line"},
)
lines := alice.readUntil(func(l string) bool {
return strings.Contains(l, "second line")
})
want := []string{
":carol!carol@* " + command + " alice :first line",
":carol!carol@* " + command + " alice :second line",
}
got := lines[max(0, len(lines)-len(want)):]
if !slices.Equal(got, want) {
t.Errorf("%s: got %q, want %q", command, got, want)
}
}
}
// TestRelayModeParameters checks that a MODE change reaches
// IRC listener members with its values as separate
// parameters, both for the bodies the HTTP API stores and
// for a change made on the IRC listener.
func TestRelayModeParameters(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
for _, step := range []struct {
body []string
want string
}{
{[]string{"+k", "secret"}, "+k secret"},
{[]string{"-k", "*"}, "-k *"},
{[]string{"+l", "50"}, "+l 50"},
{[]string{"-l"}, "-l"},
{[]string{"+b", "*!*@*.example.com"}, "+b *!*@*.example.com"},
{[]string{"-b", "*!*@*.example.com"}, "-b *!*@*.example.com"},
{[]string{"+o bob"}, "+o bob"},
{[]string{"+m"}, "+m"},
} {
env.queueMessage(t, "alice", "MODE", "op", "#modes", step.body)
lines := alice.readUntil(func(l string) bool {
return strings.Contains(l, " MODE ")
})
got := lines[len(lines)-1]
want := ":op!op@* MODE #modes " + step.want
if got != want {
t.Errorf("body %q: got %q, want %q", step.body, got, want)
}
}
bob := env.dial(t)
bob.register("bob")
alice.joinAndDrain("#modes")
bob.joinAndDrain("#modes")
alice.send("MODE #modes +v bob")
const change = " MODE #modes +v bob"
for name, client := range map[string]*testClient{
"alice": alice, "bob": bob,
} {
lines := client.readUntil(func(l string) bool {
return strings.Contains(l, " MODE ")
})
got := lines[len(lines)-1]
if !strings.HasSuffix(got, change) {
t.Errorf("%s: got %q, want it to end in %q", name, got, change)
}
}
}
// TestRelayInviteWithoutChannel checks that an INVITE whose
// body names no channel sends the invited IRC listener user
// no INVITE line.
func TestRelayInviteWithoutChannel(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
bob := env.dial(t)
bob.register("bob")
// A nil body is stored as JSON null, which is not an array.
for _, body := range [][]string{nil, {}, {""}} {
env.queueMessage(t, "bob", "INVITE", "carol", "bob", body)
}
// The relay sends a user's queue in order, so this arrives
// after anything sent for the INVITEs above.
env.queueMessage(t, "bob", "PRIVMSG", "carol", "bob",
[]string{"the end"})
lines := bob.readUntil(func(l string) bool {
return strings.HasSuffix(l, " :the end")
})
for _, line := range lines {
if strings.Contains(line, " INVITE ") {
t.Errorf("got %q, want no INVITE line", line)
}
}
}
// TestRelayLineBreaks checks that a CR, LF or NUL in a stored
// body line, key or mask never reaches an IRC listener user
// as a line break, so no extra line arrives, and that a body
// line keeps its parameter and leading ':' once they are
// removed.
func TestRelayLineBreaks(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
bob := env.dial(t)
bob.register("bob")
env.queueMessage(t, "bob", "PRIVMSG", "carol", "bob", []string{
"one\r\n:admin!a@b PRIVMSG bob :hi",
"two\n:admin!a@b NOTICE bob :hi\x00",
})
env.queueMessage(t, "bob", "MODE", "op", "#breaks",
[]string{"+k", "key\r\nERROR :Closing link"})
env.queueMessage(t, "bob", "MODE", "op", "#breaks",
[]string{"+b", "*!*@*\r\n:admin!a@b KICK #breaks bob"})
env.queueMessage(t, "bob", "PRIVMSG", "carol", "bob",
[]string{"\r\n", "\x00:hi"})
env.queueMessage(t, "bob", "PRIVMSG", "carol", "bob",
[]string{"the end"})
lines := bob.readUntil(func(l string) bool {
return strings.HasSuffix(l, " :the end")
})
want := []string{
":carol!carol@* PRIVMSG bob :one:admin!a@b PRIVMSG bob :hi",
":carol!carol@* PRIVMSG bob :two:admin!a@b NOTICE bob :hi",
":op!op@* MODE #breaks +k :keyERROR :Closing link",
":op!op@* MODE #breaks +b :*!*@*:admin!a@b KICK #breaks bob",
":carol!carol@* PRIVMSG bob :",
":carol!carol@* PRIVMSG bob ::hi",
":carol!carol@* PRIVMSG bob :the end",
}
got := lines[max(0, len(lines)-len(want)):]
if !slices.Equal(got, want) {
t.Errorf("got %q, want %q", got, want)
}
}
// TestRelayNickDiffersOnlyInCase checks that an IRC listener
// user alice receives a JOIN, PRIVMSG, PART, NICK and QUIT
// from Alice in a channel they share: nicks are
// case-sensitive, so Alice is someone else.
func TestRelayNickDiffersOnlyInCase(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#case")
capitalAlice := env.dial(t)
capitalAlice.register("Alice")
for _, step := range []struct {
line, want string
}{
{"JOIN #case", ":Alice!Alice@* JOIN #case"},
{"PRIVMSG #case :hi there", ":Alice!Alice@* PRIVMSG #case :hi there"},
{"PART #case :see you", ":Alice!Alice@* PART #case :see you"},
{"JOIN #case", ":Alice!Alice@* JOIN #case"},
{"NICK Alicia", ":Alice!Alice@* NICK Alicia"},
{"NICK Alice", ":Alicia!Alicia@* NICK Alice"}, // back for QUIT
{"QUIT", ":Alice!Alice@* QUIT :Quit: Connection closed"},
} {
capitalAlice.send(step.line)
got := alice.readLine()
if got != step.want {
t.Errorf("%s: got %q, want %q", step.line, got, step.want)
}
}
}
// TestRelayOwnChangeOnce checks that an IRC listener user who
// sets a topic, changes a channel mode or kicks gets one TOPIC,
// MODE or KICK line for it, and so does the other member.
func TestRelayOwnChangeOnce(t *testing.T) {
t.Parallel()
for _, step := range []struct {
command, line string
}{
{"TOPIC", "TOPIC #dup :new topic"},
{"MODE", "MODE #dup +m"},
{"KICK", "KICK #dup carl :bye"},
} {
t.Run(step.command, func(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#dup")
carl := env.dial(t)
carl.register("carl")
carl.joinAndDrain("#dup")
alice.send(step.line)
// The reply to alice's next command comes after her
// command has finished, so her own copy comes before it.
beforePong := alice.sendAndExpect("PING :done", "PONG")
// The relay sends a user's queue in order, so a message
// queued now arrives after any relayed copy.
for nick, client := range map[string]*testClient{
"alice": alice, "carl": carl,
} {
env.queueMessage(
t, nick, "PRIVMSG", "dave", nick,
[]string{"after the change"},
)
lines := client.readUntil(func(l string) bool {
return strings.HasSuffix(l, " :after the change")
})
if client == alice {
lines = slices.Concat(beforePong, lines)
}
var changes []string
for _, line := range lines {
if strings.Contains(line, " "+step.command+" #dup ") {
changes = append(changes, line)
}
}
if len(changes) != 1 {
t.Errorf("%s got %q, want one %s line",
nick, changes, step.command)
}
}
})
}
}
// TestRelayOwnSessionOnce checks that a command a user makes
// from one client of their session reaches each of the
// session's clients once: an IRC listener connection and an
// HTTP API client. The connection that sent a command gets
// only its own echo, and none for PRIVMSG and NOTICE, which
// IRC does not echo.
//
//nolint:funlen // table-driven test
func TestRelayOwnSessionOnce(t *testing.T) {
t.Parallel()
hello := json.RawMessage(`["hello"]`)
for _, step := range []struct {
// line is the command as alice sends it on the IRC
// listener.
line string
// api makes the same command for her session the way
// the HTTP API does.
api func(
ctx context.Context, svc *service.Service, sessionID int64,
) error
// echoes is how many copies alice's connection gets of
// line when it sends line itself.
echoes int
}{
{"PRIVMSG #chan :hello", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
_, err := svc.SendChannelMessage(
ctx, sessionID, 0, "alice", "PRIVMSG", "#chan", hello, nil,
)
return err
}, 0},
{"NOTICE bob :hello", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
_, err := svc.SendDirectMessage(
ctx, sessionID, 0, "alice", "NOTICE", "bob", hello, nil,
)
return err
}, 0},
{"JOIN #other", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
_, err := svc.JoinChannel(ctx, sessionID, 0, "alice", "#other", "")
return err
}, 1},
{"PART #chan :bye", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
return svc.PartChannel(ctx, sessionID, 0, "alice", "#chan", "bye")
}, 1},
{"NICK alice2", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
return svc.ChangeNick(ctx, sessionID, 0, "alice", "alice2")
}, 1},
{"TOPIC #chan :new topic", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
return svc.SetTopic(
ctx, sessionID, 0, "alice", "#chan", "new topic",
)
}, 1},
{"MODE #chan +m", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
chID, err := svc.ValidateChannelOp(ctx, sessionID, "#chan")
if err != nil {
return err
}
err = svc.SetChannelFlag(ctx, chID, 'm', true)
if err != nil {
return err
}
svc.BroadcastMode(ctx, 0, "alice", "#chan", chID, "+m")
return nil
}, 1},
{"KICK #chan bob :bye", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
return svc.KickUser(
ctx, sessionID, 0, "alice", "#chan", "bob", "bye",
)
}, 1},
} {
command := strings.Fields(step.line)[0]
t.Run(command+" from IRC", func(t *testing.T) {
t.Parallel()
ircCopies, httpCopies := ownSessionCopies(t, step.line, nil)
if ircCopies != step.echoes || httpCopies != 1 {
t.Errorf("connection got %d, HTTP client got %d, want %d and 1",
ircCopies, httpCopies, step.echoes)
}
})
t.Run(command+" from the HTTP API", func(t *testing.T) {
t.Parallel()
ircCopies, httpCopies := ownSessionCopies(t, step.line, step.api)
if ircCopies != 1 || httpCopies != 1 {
t.Errorf("connection got %d, HTTP client got %d, want 1 and 1",
ircCopies, httpCopies)
}
})
}
}
// ownSessionCopies sets up alice with aliceWithHTTPClient. It
// makes the command line from her connection, or with api from
// the HTTP API client when api is not nil, and returns how many
// copies of it her connection and her HTTP API client got.
func ownSessionCopies(
t *testing.T,
line string,
api func(ctx context.Context, svc *service.Service, sessionID int64) error,
) (int, int) {
t.Helper()
ctx := t.Context()
env := newTestEnv(t)
alice, _, sessionID, httpClientID := aliceWithHTTPClient(t, env)
var lines []string
if api == nil {
alice.send(line)
// Her echo comes before the reply to her next command.
lines = alice.sendAndExpect("PING :done", "PONG")
} else {
err := api(ctx, env.srv.Service(), sessionID)
if err != nil {
t.Fatalf("%s over the HTTP API: %v", line, err)
}
}
msgs, _, err := env.database.PollMessages(ctx, httpClientID, 0, 0)
if err != nil {
t.Fatalf("poll the HTTP API client: %v", err)
}
httpCopies := 0
for _, msg := range msgs {
if msg.Command == strings.Fields(line)[0] {
httpCopies++
}
}
// The relay sends a queue in order, so this arrives after any
// copy. It is queued by session, as NICK changes her nick.
endID, _, err := env.database.InsertMessage(
ctx, "PRIVMSG", "dave", "alice", 0, 0,
nil, json.RawMessage(`["the end"]`), nil,
)
if err != nil {
t.Fatalf("insert message: %v", err)
}
err = env.database.EnqueueToSession(ctx, sessionID, endID, 0)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
env.brk.Notify(sessionID)
lines = append(lines, alice.readUntil(func(l string) bool {
return strings.HasSuffix(l, " :the end")
})...)
// A copy starts with the command and its first parameter.
start := " " + strings.Join(strings.Fields(line)[:2], " ")
ircCopies := strings.Count(strings.Join(lines, "\n"), start)
return ircCopies, httpCopies
}
// aliceWithHTTPClient connects alice to the IRC listener and
// has her join #chan, which bob is on too, then adds an HTTP API
// client to her session with the password her connection set,
// as POST /api/v1/login does. It returns her connection, bob's
// connection, her session and her HTTP API client.
func aliceWithHTTPClient(
t *testing.T, env *testEnv,
) (*testClient, *testClient, int64, int64) {
t.Helper()
const password = "alicepass"
alice := env.dial(t)
alice.send("PASS " + password)
alice.register("alice")
// Registration stores the password before the JOIN runs.
alice.joinAndDrain("#chan")
bob := env.dial(t)
bob.register("bob")
bob.joinAndDrain("#chan")
sessionID, httpClientID, _, err := env.database.LoginUser(
t.Context(), "alice", password, "127.0.0.1", "localhost",
)
if err != nil {
t.Fatalf("log in as alice: %v", err)
}
return alice, bob, sessionID, httpClientID
}
// TestRelayNickChangeFromHTTPAPI checks that once alice's nick
// is changed from her HTTP API client, her IRC listener
// connection uses the new nick: what she then sends from it
// reaches bob under the new nick.
func TestRelayNickChangeFromHTTPAPI(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice, bob, sessionID, _ := aliceWithHTTPClient(t, env)
bob.joinAndDrain("#other")
// As the HTTP API does.
err := env.srv.Service().ChangeNick(
t.Context(), sessionID, 0, "alice", "alice2",
)
if err != nil {
t.Fatalf("change nick over the HTTP API: %v", err)
}
// Her connection has taken the new nick once it has sent her
// the change.
const change = ":alice!alice@* NICK alice2"
alice.readUntil(func(l string) bool { return l == change })
bob.readUntil(func(l string) bool { return l == change })
for _, step := range []struct {
line, want string
}{
{"PRIVMSG #chan :hi there", ":alice2!alice2@* PRIVMSG #chan :hi there"},
{"JOIN #other", ":alice2!alice2@* JOIN #other"},
{"NICK alice3", ":alice2!alice2@* NICK alice3"},
} {
alice.send(step.line)
got := bob.readLine()
if got != step.want {
t.Errorf("%s: got %q, want %q", step.line, got, step.want)
}
}
}
// TestRelayNickChangeKeepsTakenNick checks that an IRC
// listener connection which takes a nick another user has
// given up keeps it when that user's change reaches it
// afterwards: what it then sends goes out under the nick its
// session holds.
func TestRelayNickChangeKeepsTakenNick(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
bob := env.dial(t)
bob.register("bob")
bob.sendAndExpect("NICK bob2", " NICK bob2")
alice := env.dial(t)
alice.register("alice")
alice.sendAndExpect("NICK bob", " NICK bob")
alice.joinAndDrain("#chan")
bob.joinAndDrain("#chan")
// Bob's change reaches alice only now, as it does when she
// takes his old nick before her connection has passed the
// change on. She holds bob now, so it is queued to bob.
env.queueMessage(t, "bob", "NICK", "bob", "", []string{"bob2"})
alice.readUntil(func(l string) bool {
return l == ":bob!bob@* NICK bob2"
})
alice.send("PRIVMSG #chan :hi there")
const want = ":bob!bob@* PRIVMSG #chan :hi there"
got := bob.readLine()
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
+62 -3
View File
@@ -6,6 +6,7 @@ import (
"log/slog" "log/slog"
"net" "net"
"sync" "sync"
"time"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/broker"
@@ -15,6 +16,10 @@ import (
"sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/service"
) )
// stopTimeout is the longest stop waits for the connections'
// cleanup, which ends their sessions.
const stopTimeout = 5 * time.Second
// Params defines the dependencies for creating an IRC // Params defines the dependencies for creating an IRC
// Server. // Server.
type Params struct { type Params struct {
@@ -38,6 +43,19 @@ type Server struct {
mu sync.Mutex mu sync.Mutex
conns map[*Conn]struct{} conns map[*Conn]struct{}
cancel context.CancelFunc cancel context.CancelFunc
// serving counts the connections whose serve, cleanup
// included, has not returned yet.
serving sync.WaitGroup
// pingInterval and pongDeadline are the constants of the
// same names, except in tests, which shorten them.
pingInterval time.Duration
pongDeadline time.Duration
// resolveHost is the function of the same name, except in
// tests, which replace it with one that blocks.
resolveHost func(ctx context.Context, addr string) string
} }
// New creates a new IRC Server and registers its lifecycle // New creates a new IRC Server and registers its lifecycle
@@ -57,6 +75,11 @@ func New(
listener: nil, listener: nil,
cancel: nil, cancel: nil,
mu: sync.Mutex{}, mu: sync.Mutex{},
serving: sync.WaitGroup{},
pingInterval: pingInterval,
pongDeadline: pongDeadline,
resolveHost: resolveHost,
} }
listenAddr := params.Config.IRCListenAddr listenAddr := params.Config.IRCListenAddr
@@ -103,7 +126,10 @@ func (s *Server) start(ctx context.Context, addr string) error {
return nil return nil
} }
// stop shuts down the listener and all connections. // stop shuts down the listener and all connections, then
// waits up to stopTimeout for every connection's cleanup to
// finish, so that their sessions are ended before the
// database closes.
func (s *Server) stop() { func (s *Server) stop() {
if s.cancel != nil { if s.cancel != nil {
s.cancel() s.cancel()
@@ -118,6 +144,22 @@ func (s *Server) stop() {
c.conn.Close() //nolint:errcheck,gosec c.conn.Close() //nolint:errcheck,gosec
} }
s.mu.Unlock() s.mu.Unlock()
done := make(chan struct{})
go func() {
s.serving.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(stopTimeout):
s.log.Error(
"irc connections not cleaned up before stop timeout",
"timeout", stopTimeout,
)
}
} }
// acceptLoop accepts new connections. // acceptLoop accepts new connections.
@@ -138,22 +180,39 @@ func (s *Server) acceptLoop(ctx context.Context) {
} }
client := newConn( client := newConn(
ctx, tcpConn, s.log, tcpConn, s.log,
s.database, s.brk, s.cfg, s.svc, s.database, s.brk, s.cfg, s.svc,
) )
s.mu.Lock() s.mu.Lock()
// stop cancels ctx before it takes s.mu to close the
// connections, so a connection accepted while stop
// runs is either closed and waited for by stop or
// closed here.
if ctx.Err() != nil {
s.mu.Unlock()
tcpConn.Close() //nolint:errcheck,gosec
return
}
s.conns[client] = struct{}{} s.conns[client] = struct{}{}
s.serving.Add(1)
s.mu.Unlock() s.mu.Unlock()
go func() { go func() {
defer s.serving.Done()
defer func() { defer func() {
s.mu.Lock() s.mu.Lock()
delete(s.conns, client) delete(s.conns, client)
s.mu.Unlock() s.mu.Unlock()
}() }()
client.serve(ctx) client.serve(
ctx, s.resolveHost,
s.pingInterval, s.pongDeadline,
)
}() }()
} }
} }
+760 -16
View File
@@ -2,20 +2,27 @@ package ircserver_test
import ( import (
"bufio" "bufio"
"context"
"crypto/rand"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
"os" "os"
"runtime" "runtime"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"time" "time"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/ircserver" "sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/logger"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -36,12 +43,75 @@ type testEnv struct {
srv *ircserver.Server srv *ircserver.Server
} }
// testServerName is the SERVER_NAME of newTestEnv.
const testServerName = "test.irc"
func newTestEnv(t *testing.T) *testEnv { func newTestEnv(t *testing.T) *testEnv {
t.Helper() t.Helper()
return newTestEnvWithConfig(t, &config.Config{
ServerName: testServerName,
MOTD: "Welcome to test IRC",
})
}
// newTestEnvWithOper is newTestEnv with the operator name
// testoper and password testpass.
func newTestEnvWithOper(t *testing.T) *testEnv {
t.Helper()
return newTestEnvWithConfig(t, &config.Config{
ServerName: testServerName,
MOTD: "Welcome to test IRC",
OperName: "testoper",
OperPassword: "testpass",
})
}
// newTestEnvWithConfig starts an IRC server with cfg on a
// fresh database.
func newTestEnvWithConfig(
t *testing.T,
cfg *config.Config,
) *testEnv {
t.Helper()
return newTestEnvWithPingTimes(
t, cfg, ircserver.PingInterval, ircserver.PongDeadline,
)
}
// newTestEnvWithPingTimes is newTestEnvWithConfig, except
// that the server sends a connection a PING after interval
// with nothing read, and closes it when nothing is read
// within deadline after that.
func newTestEnvWithPingTimes(
t *testing.T,
cfg *config.Config,
interval, deadline time.Duration,
) *testEnv {
t.Helper()
return newTestEnvWithResolver(
t, cfg, interval, deadline, ircserver.ResolveHost,
)
}
// newTestEnvWithResolver is newTestEnvWithPingTimes, except
// that the server looks up a client's hostname with resolve.
func newTestEnvWithResolver(
t *testing.T,
cfg *config.Config,
interval, deadline time.Duration,
resolve func(ctx context.Context, addr string) string,
) *testEnv {
t.Helper()
// A random name, so that no other test environment, not
// even an earlier run of the same test, can share it.
dsn := fmt.Sprintf( dsn := fmt.Sprintf(
"file:%s?mode=memory&cache=shared&_journal_mode=WAL", "file:%s?mode=memory&cache=shared",
t.Name(), rand.Text(),
) )
conn, err := sql.Open("sqlite", dsn) conn, err := sql.Open("sqlite", dsn)
@@ -49,6 +119,13 @@ func newTestEnv(t *testing.T) *testEnv {
t.Fatalf("open db: %v", err) t.Fatalf("open db: %v", err)
} }
t.Cleanup(func() {
err := conn.Close()
if err != nil {
t.Logf("close db: %v", err)
}
})
conn.SetMaxOpenConns(1) conn.SetMaxOpenConns(1)
_, err = conn.ExecContext( _, err = conn.ExecContext(
@@ -67,11 +144,6 @@ func newTestEnv(t *testing.T) *testEnv {
brk := broker.New() brk := broker.New()
cfg := &config.Config{ //nolint:exhaustruct
ServerName: "test.irc",
MOTD: "Welcome to test IRC",
}
var listenConfig net.ListenConfig var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0") listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
@@ -91,21 +163,18 @@ func newTestEnv(t *testing.T) *testEnv {
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct &slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
)) ))
srv := ircserver.NewTestServer(log, cfg, database, brk) srv := ircserver.NewTestServer(
log, cfg, database, brk, interval, deadline, resolve,
)
err = srv.Start(addr) err = srv.Start(addr)
if err != nil { if err != nil {
t.Fatalf("start irc server: %v", err) t.Fatalf("start irc server: %v", err)
} }
t.Cleanup(func() { // Cleanups run last registered first, so the server stops
srv.Stop() // before its database is closed.
t.Cleanup(srv.Stop)
err := conn.Close()
if err != nil {
t.Logf("close db: %v", err)
}
})
return &testEnv{ return &testEnv{
database: database, database: database,
@@ -144,6 +213,34 @@ func (env *testEnv) dial(t *testing.T) *testClient {
} }
} }
// The ping interval and PONG deadline for
// newTestEnvWithShortPings. They differ, so that a test can
// tell them apart; the PONG deadline is how long a test
// client has to answer a PING.
const (
testPingInterval = time.Second
testPongDeadline = 300 * time.Millisecond
)
// newTestEnvWithShortPings starts an IRC server that uses
// testPingInterval and testPongDeadline in place of
// pingInterval and pongDeadline.
func newTestEnvWithShortPings(t *testing.T) *testEnv {
t.Helper()
return newTestEnvWithPingTimes(
t,
&config.Config{ServerName: testServerName},
testPingInterval, testPongDeadline,
)
}
// isServerPing reports whether line is the PING the test
// server sends a quiet client.
func isServerPing(line string) bool {
return line == "PING :"+testServerName
}
// testClient wraps a raw TCP connection with helpers. // testClient wraps a raw TCP connection with helpers.
type testClient struct { type testClient struct {
t *testing.T t *testing.T
@@ -215,6 +312,51 @@ func (tc *testClient) register(nick string) []string {
}) })
} }
// readUntilClosed returns the lines received until the
// server closes the connection, and fails the test if it is
// still open after testTimeout.
func (tc *testClient) readUntilClosed() []string {
tc.t.Helper()
_ = tc.conn.SetReadDeadline(
time.Now().Add(testTimeout),
)
var lines []string
for tc.scanner.Scan() {
lines = append(lines, tc.scanner.Text())
}
err := tc.scanner.Err()
if err != nil {
tc.t.Fatalf(
"connection not closed: %v (lines: %v)",
err, lines,
)
}
return lines
}
// readServerPing reads until the PING the test server sends a
// quiet client, and fails the test if it came sooner than
// testPingInterval after lastSent, a time just before the
// client sent its last line.
func (tc *testClient) readServerPing(lastSent time.Time) {
tc.t.Helper()
tc.readUntil(isServerPing)
waited := time.Since(lastSent)
if waited < testPingInterval {
tc.t.Errorf(
"PING came %v after the client's last line, want at least %v",
waited, testPingInterval,
)
}
}
// assertContains checks that at least one line matches the // assertContains checks that at least one line matches the
// given substring. // given substring.
func assertContains( func assertContains(
@@ -233,6 +375,27 @@ func assertContains(
t.Errorf("did not find %q in output: %s", substr, description) t.Errorf("did not find %q in output: %s", substr, description)
} }
// assertNotContains checks that no line matches the given
// substring.
func assertNotContains(
t *testing.T,
lines []string,
substr, description string,
) {
t.Helper()
for _, line := range lines {
if strings.Contains(line, substr) {
t.Errorf(
"unexpectedly found %q in output: %s",
substr, description,
)
return
}
}
}
// joinAndDrain joins a channel and reads until // joinAndDrain joins a channel and reads until
// RPL_ENDOFNAMES. // RPL_ENDOFNAMES.
func (tc *testClient) joinAndDrain(channel string) { func (tc *testClient) joinAndDrain(channel string) {
@@ -269,6 +432,63 @@ func TestRegistration(t *testing.T) {
assertContains(t, lines, " 001 ", "RPL_WELCOME") assertContains(t, lines, " 001 ", "RPL_WELCOME")
} }
// TestRegistrationWhileAnotherLookupIsPending checks that a
// client is accepted and registered while the hostname lookup
// of an earlier client has not returned, and that it
// registers with the hostname its own lookup returned.
func TestRegistrationWhileAnotherLookupIsPending(t *testing.T) {
t.Parallel()
const lookedUpHost = "client.example"
var lookups atomic.Int32
firstLookupStarted := make(chan struct{})
// The first lookup returns only when the server stops.
resolve := func(ctx context.Context, _ string) string {
if lookups.Add(1) == 1 {
close(firstLookupStarted)
<-ctx.Done()
}
return lookedUpHost
}
env := newTestEnvWithResolver(
t, &config.Config{ServerName: testServerName},
ircserver.PingInterval, ircserver.PongDeadline, resolve,
)
env.dial(t)
select {
case <-firstLookupStarted:
case <-time.After(testTimeout):
t.Fatal("the first client's lookup did not start")
}
lines := env.dial(t).register("second")
assertContains(
t, lines, "second!second@"+lookedUpHost,
"RPL_WELCOME with the looked-up hostname",
)
}
// TestEachTestEnvHasItsOwnDatabase checks that a test
// environment starts on an empty database while another one
// is still open.
func TestEachTestEnvHasItsOwnDatabase(t *testing.T) {
t.Parallel()
first := newTestEnv(t)
first.dial(t).register("samenick")
second := newTestEnv(t)
lines := second.dial(t).register("samenick")
assertContains(t, lines, " 001 ", "RPL_WELCOME")
}
func TestWelcomeContainsNick(t *testing.T) { func TestWelcomeContainsNick(t *testing.T) {
t.Parallel() t.Parallel()
@@ -296,6 +516,164 @@ func TestPingPong(t *testing.T) {
assertContains(t, lines, "PONG", "PONG response") assertContains(t, lines, "PONG", "PONG response")
} }
// TestQuietClientAnsweringPingStaysConnected checks that a
// registered client that sends nothing is sent a PING once
// the ping interval has passed since its last line, and stays
// connected as long as it answers each one, whether with a
// PONG or with another line.
func TestQuietClientAnsweringPingStaysConnected(t *testing.T) {
t.Parallel()
tests := []struct {
name string
answer string
}{
{name: "PONG", answer: "PONG :" + testServerName},
{name: "PING of its own", answer: "PING :answer"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
env := newTestEnvWithShortPings(t)
client := env.dial(t)
lastSent := time.Now()
client.register("answers")
// The second PING comes later than a client that
// answers none stays connected.
for range 2 {
client.readServerPing(lastSent)
lastSent = time.Now()
client.send(tt.answer)
}
client.sendAndExpect("PING :still here", "still here")
})
}
}
// TestQuietClientNotAnsweringPingIsClosed checks that a
// registered client that does not answer the PING is
// disconnected once the PONG deadline has passed after it.
func TestQuietClientNotAnsweringPingIsClosed(t *testing.T) {
t.Parallel()
// latest leaves room for a loaded host, but is sooner than
// a read deadline of two ping intervals would close it.
const (
earliest = testPingInterval + testPongDeadline
latest = 2 * testPingInterval
)
env := newTestEnvWithShortPings(t)
client := env.dial(t)
lastSent := time.Now()
client.register("silent")
client.readServerPing(lastSent)
client.readUntilClosed()
waited := time.Since(lastSent)
if waited < earliest || waited >= latest {
t.Errorf(
"closed %v after the client's last line, want %v to %v",
waited, earliest, latest,
)
}
}
// TestUnregisteredClientAnswersPing checks that a PONG before
// registration, the answer to the PING unregistered clients
// are sent too, gets no ERR_NOTREGISTERED.
func TestUnregisteredClientAnswersPing(t *testing.T) {
t.Parallel()
env := newTestEnvWithShortPings(t)
client := env.dial(t)
client.readUntil(isServerPing)
client.send("PONG :" + testServerName)
lines := client.sendAndExpect("PING :after", "PONG")
assertNotContains(t, lines, " 451 ", "ERR_NOTREGISTERED")
}
// TestNewPingTimes checks that a server made by New sends a
// PING after 90 seconds with nothing read, and closes the
// connection 30 seconds after that, as README.md states.
func TestNewPingTimes(t *testing.T) {
t.Parallel()
const (
wantInterval = 90 * time.Second
wantDeadline = 30 * time.Second
)
log, err := logger.New(nil, logger.Params{
Globals: &globals.Globals{},
})
if err != nil {
t.Fatal(err)
}
// With no IRC_LISTEN_ADDR, New starts no listener and
// uses nothing else from its params.
srv := ircserver.New(fxtest.NewLifecycle(t), ircserver.Params{
Logger: log,
Config: &config.Config{},
})
interval, deadline := srv.PingTimes()
if interval != wantInterval || deadline != wantDeadline {
t.Errorf(
"PING after %v and close %v later, want %v and %v",
interval, deadline, wantInterval, wantDeadline,
)
}
}
// TestIdleCleanupKeepsActiveUser checks that a user who
// sends commands is kept by the idle cleanup, and a user who
// sends nothing is not.
func TestIdleCleanupKeepsActiveUser(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
active := env.dial(t)
active.register("active")
idle := env.dial(t)
idle.register("idle")
// Both users were last seen before cutoff, and only active
// sends a command after it, so the result does not depend
// on how long the PONG takes to arrive.
cutoff := time.Now()
active.sendAndExpect("PING :still here", "PONG")
// The idle cleanup in internal/handlers removes exactly
// the users this returns.
stale, err := env.database.GetStaleOrphanSessions(
t.Context(), cutoff,
)
if err != nil {
t.Fatalf("get stale sessions: %v", err)
}
if len(stale) != 1 || stale[0].Nick != "idle" {
t.Errorf("cleanup removes %v, want only idle", stale)
}
}
func TestJoinChannel(t *testing.T) { func TestJoinChannel(t *testing.T) {
t.Parallel() t.Parallel()
@@ -313,6 +691,37 @@ func TestJoinChannel(t *testing.T) {
assertContains(t, lines, " 366 ", "RPL_ENDOFNAMES") assertContains(t, lines, " 366 ", "RPL_ENDOFNAMES")
} }
// TestJoinInvalidChannelName checks that JOIN refuses a name
// that is not # followed by 1 to 63 letters, digits, _ or -
// with ERR_NOSUCHCHANNEL, and creates no such channel.
func TestJoinInvalidChannelName(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("joiner")
for _, channel := range []string{
"#bad.name",
"#",
"#" + strings.Repeat("a", 64),
} {
lines := client.sendAndExpect("JOIN "+channel, " 403 ")
assertContains(
t, lines,
" 403 joiner "+channel+" :No such channel",
channel,
)
_, err := env.database.GetChannelByName(t.Context(), channel)
if err == nil {
t.Errorf("channel %q was created", channel)
}
}
}
func TestPrivmsgBetweenClients(t *testing.T) { func TestPrivmsgBetweenClients(t *testing.T) {
t.Parallel() t.Parallel()
@@ -363,6 +772,68 @@ func TestDuplicateNick(t *testing.T) {
assertContains(t, lines, " 433 ", "ERR_NICKNAMEINUSE") assertContains(t, lines, " 433 ", "ERR_NICKNAMEINUSE")
} }
// TestNickRefused checks that NICK, before and after
// registration, answers a nick the HTTP API refuses with
// ERR_ERRONEUSNICKNAME and keeps the nick as it was, and
// that another member of the channel sees no line from it.
func TestNickRefused(t *testing.T) {
t.Parallel()
// Each is what follows "NICK " on the wire.
refused := []string{
":bob PRIVMSG #inj :hi", // contains spaces
":", // empty
strings.Repeat("a", 33), // one character too long
"bob!x",
"bob@x",
"#bob",
}
env := newTestEnv(t)
// Before registration: the 432 is addressed to the nick
// sent first, and USER registers with that nick.
early := env.dial(t)
early.send("NICK early")
for _, param := range refused {
lines := early.sendAndExpect("NICK "+param, " 432 ")
assertContains(t, lines, " 432 early ", "before: "+param)
}
welcome := early.sendAndExpect("USER early 0 * :Test", " 001 ")
assertContains(t, welcome, " 001 early ", "registered as early")
// After registration.
bob := env.dial(t)
bob.register("bob")
bob.joinAndDrain("#inj")
mallory := env.dial(t)
mallory.register("mallory")
mallory.joinAndDrain("#inj")
bob.readUntil(func(l string) bool {
return strings.Contains(l, "JOIN") &&
strings.Contains(l, "mallory")
})
for _, param := range refused {
lines := mallory.sendAndExpect("NICK "+param, " 432 ")
assertContains(t, lines, " 432 mallory ", "after: "+param)
}
mallory.send("PRIVMSG #inj :still mallory")
bobLines := bob.readUntil(func(l string) bool {
return strings.Contains(l, "still mallory")
})
assertNotContains(t, bobLines, " NICK ", "bob sees no NICK")
assertContains(
t, bobLines, ":mallory!", "bob sees mallory's message",
)
}
func TestListChannels(t *testing.T) { func TestListChannels(t *testing.T) {
t.Parallel() t.Parallel()
@@ -404,6 +875,120 @@ func TestQuit(t *testing.T) {
assertContains(t, lines, "goodbye", "QUIT reason") assertContains(t, lines, "goodbye", "QUIT reason")
} }
// TestStopEndsSessions checks that stopping the server ends
// a registered connection's session before Stop returns, as
// closing the connection does: the session, its client and
// its channel membership are gone from the database, and a
// channel peer has been sent the QUIT.
func TestStopEndsSessions(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("shutirc")
client.joinAndDrain("#shut")
ctx := t.Context()
// A peer on the HTTP API, whose session outlives the stop.
peerID, peerClientID, _, err := env.database.CreateSession(
ctx, "peer", "peer", "", "",
)
if err != nil {
t.Fatalf("create peer session: %v", err)
}
channelID, err := env.database.GetChannelByName(ctx, "#shut")
if err != nil {
t.Fatalf("get channel: %v", err)
}
err = env.database.JoinChannel(ctx, channelID, peerID)
if err != nil {
t.Fatalf("join peer: %v", err)
}
env.srv.Stop()
_, err = env.database.GetSessionByNick(ctx, "shutirc")
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("look up shutirc after stop: %v, want no rows", err)
}
clients, err := env.database.GetClientCount(ctx)
if err != nil {
t.Fatalf("count clients: %v", err)
}
if clients != 1 {
t.Errorf("%d clients after stop, want only the peer's", clients)
}
members, err := env.database.GetChannelMemberIDs(ctx, channelID)
if err != nil {
t.Fatalf("get members: %v", err)
}
if len(members) != 1 || members[0] != peerID {
t.Errorf(
"members of #shut after stop: %v, want only the peer %d",
members, peerID,
)
}
msgs, _, err := env.database.PollMessages(ctx, peerClientID, 0, 0)
if err != nil {
t.Fatalf("poll peer: %v", err)
}
for _, msg := range msgs {
if msg.Command == "QUIT" && msg.From == "shutirc" {
return
}
}
t.Errorf("peer was not sent the QUIT of shutirc: %v", msgs)
}
// TestStopReturnsWhileCleanupIsStuck checks that Stop stops
// waiting for a connection's cleanup after StopTimeout, so a
// cleanup that cannot finish does not hold up stopping.
func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("stuck")
// The test database has one connection. While the test
// holds it, the cleanup's first query waits for it.
held, err := env.database.GetDB().Conn(t.Context())
if err != nil {
t.Fatalf("take the database connection: %v", err)
}
stopped := make(chan struct{})
go func() {
env.srv.Stop()
close(stopped)
}()
select {
case <-stopped:
case <-time.After(ircserver.StopTimeout + testTimeout):
t.Error("Stop did not return while a cleanup was stuck")
}
// Lets the cleanup finish before the database is closed.
err = held.Close()
if err != nil {
t.Errorf("release the database connection: %v", err)
}
}
func TestTopicSetAndGet(t *testing.T) { func TestTopicSetAndGet(t *testing.T) {
t.Parallel() t.Parallel()
@@ -575,6 +1160,165 @@ func TestHandlePassPostRegistration(t *testing.T) {
) )
} }
// The shortest and longest password PASS accepts; bcrypt
// cannot hash more than 72 bytes.
const (
minPasswordBytes = 8
maxPasswordBytes = 72
)
// passwordLengthNotice is the NOTICE text for a password that
// is too short or too long.
const passwordLengthNotice = "Password must be 8 to 72 bytes"
// passwordCase is a password a PASS test sends, and whether
// the server should store it.
type passwordCase struct {
name string
password string
stored bool
}
// passwordCases returns the passwords the PASS length tests
// send: one of 8 to 72 bytes is stored and any other is
// refused. The limit counts bytes: "é" is two bytes in UTF-8,
// so 72 of them are refused.
func passwordCases() []passwordCase {
return []passwordCase{
{"empty", "", false},
{"7 bytes", strings.Repeat("a", minPasswordBytes-1), false},
{"8 bytes", strings.Repeat("a", minPasswordBytes), true},
{"72 bytes", strings.Repeat("a", maxPasswordBytes), true},
{"73 bytes", strings.Repeat("a", maxPasswordBytes+1), false},
{"72 characters", strings.Repeat("é", maxPasswordBytes), false},
}
}
// assertStored fails the test unless nick can log in with
// password exactly when stored is true.
func assertStored(
t *testing.T, env *testEnv, nick, password string, stored bool,
) {
t.Helper()
_, _, token, err := env.database.LoginUser(
t.Context(), nick, password, "127.0.0.1", "localhost",
)
loggedIn := err == nil && token != ""
if loggedIn != stored {
t.Errorf(
"log in as %s: logged in %v, want %v (error: %v)",
nick, loggedIn, stored, err,
)
}
}
// TestPassPostRegistrationLength checks that PASS after
// registration stores and confirms a password of 8 to 72
// bytes, and refuses any other with a NOTICE.
func TestPassPostRegistrationLength(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
for i, tc := range passwordCases() {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
nick := fmt.Sprintf("postpass%d", i)
client := env.dial(t)
client.register(nick)
client.send("PASS :" + tc.password)
// The server answers commands in order, so its
// reply to PASS comes before the PONG.
lines := client.sendAndExpect("PING :after", "PONG")
reply := passwordLengthNotice
if tc.stored {
reply = "Password set"
}
assertContains(t, lines, reply, "reply to PASS")
assertStored(t, env, nick, tc.password, tc.stored)
})
}
}
// TestPassPostRegistrationNotStored checks that PASS is not
// confirmed when the password could not be stored.
func TestPassPostRegistrationNotStored(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("failpass")
_, err := env.database.GetDB().ExecContext(t.Context(),
`CREATE TRIGGER refuse_password
BEFORE UPDATE OF password_hash ON sessions
BEGIN SELECT RAISE(ABORT, 'refused'); END`,
)
if err != nil {
t.Fatalf("create trigger: %v", err)
}
client.send("PASS :mypassword123")
// The server answers commands in order, so anything it
// sent about PASS comes before the PONG.
lines := client.sendAndExpect("PING :after", "PONG")
assertNotContains(
t, lines, "Password set",
"confirmation of a password that was not stored",
)
}
// TestPassBeforeRegistrationLength checks that a PASS before
// registration stores a password of 8 to 72 bytes, and that
// any other is refused with a NOTICE once registration
// completes.
func TestPassBeforeRegistrationLength(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
for i, tc := range passwordCases() {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
nick := fmt.Sprintf("prepass%d", i)
client := env.dial(t)
client.send("PASS :" + tc.password)
client.register(nick)
// The password is checked after the welcome
// burst, which register reads; the PONG comes
// after that.
lines := client.sendAndExpect("PING :after", "PONG")
if tc.stored {
assertNotContains(
t, lines, passwordLengthNotice,
"refusal of a valid password",
)
} else {
assertContains(
t, lines, passwordLengthNotice,
"refusal after registration",
)
}
assertStored(t, env, nick, tc.password, tc.stored)
})
}
}
func TestPreRegistrationNotRegistered(t *testing.T) { func TestPreRegistrationNotRegistered(t *testing.T) {
t.Parallel() t.Parallel()
+22
View File
@@ -0,0 +1,22 @@
package server
import (
"net"
"github.com/go-chi/chi/v5"
)
// NewTestSPAServer creates a Server whose router serves only
// the embedded web client.
func NewTestSPAServer() *Server {
srv := &Server{router: chi.NewRouter()}
srv.setupSPA()
return srv
}
// ServeUntilShutdown runs serveUntilShutdown, which serves the
// HTTP server on listener, and returns when it does.
func (srv *Server) ServeUntilShutdown(listener net.Listener) {
srv.serveUntilShutdown(listener)
}
+4 -1
View File
@@ -3,6 +3,7 @@ package server
import ( import (
"io/fs" "io/fs"
"net/http" "net/http"
"strings"
"time" "time"
"sneak.berlin/go/neoirc/web" "sneak.berlin/go/neoirc/web"
@@ -137,8 +138,10 @@ func (srv *Server) setupSPA() {
return return
} }
// The path is empty, not "/", for a request line such
// as "GET http://host HTTP/1.1".
fileData, readErr := readFS.ReadFile( fileData, readErr := readFS.ReadFile(
request.URL.Path[1:], strings.TrimPrefix(request.URL.Path, "/"),
) )
if readErr != nil || len(fileData) == 0 { if readErr != nil || len(fileData) == 0 {
indexHTML, indexErr := readFS.ReadFile( indexHTML, indexErr := readFS.ReadFile(
+41
View File
@@ -0,0 +1,41 @@
package server_test
import (
"io/fs"
"net/http"
"net/http/httptest"
"testing"
"sneak.berlin/go/neoirc/internal/server"
"sneak.berlin/go/neoirc/web"
)
// An absolute-form request line with no path, such as
// "GET http://example.com HTTP/1.1", leaves URL.Path empty.
func TestSPAServesIndexForEmptyPath(t *testing.T) {
t.Parallel()
request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "http://example.com", nil,
)
if request.URL.Path != "" {
t.Fatalf("URL.Path is %q, want it empty", request.URL.Path)
}
recorder := httptest.NewRecorder()
server.NewTestSPAServer().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status %d, want %d", recorder.Code, http.StatusOK)
}
indexHTML, err := fs.ReadFile(web.Dist, "dist/index.html")
if err != nil {
t.Fatal(err)
}
if recorder.Body.String() != string(indexHTML) {
t.Errorf("body is not the index page")
}
}
+47 -50
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -39,17 +40,15 @@ type Params struct {
Config *config.Config Config *config.Config
Middleware *middleware.Middleware Middleware *middleware.Middleware
Handlers *handlers.Handlers Handlers *handlers.Handlers
Shutdowner fx.Shutdowner
} }
// Server is the main HTTP server. // Server is the main HTTP server.
// It manages routing, middleware, and lifecycle. // It manages routing, middleware, and lifecycle.
type Server struct { type Server struct {
startupTime time.Time startupTime time.Time
exitCode int
sentryEnabled bool sentryEnabled bool
log *slog.Logger log *slog.Logger
ctx context.Context //nolint:containedctx // signal handling pattern
cancelFunc context.CancelFunc
httpServer *http.Server httpServer *http.Server
router *chi.Mux router *chi.Mux
params Params params Params
@@ -69,7 +68,7 @@ func New(
} }
lifecycle.Append(fx.Hook{ lifecycle.Append(fx.Hook{
OnStart: func(_ context.Context) error { OnStart: func(ctx context.Context) error {
srv.startupTime = time.Now() srv.startupTime = time.Now()
// Build the router before OnStart returns, so srv can // Build the router before OnStart returns, so srv can
@@ -78,13 +77,14 @@ func New(
srv.enableSentry() srv.enableSentry()
srv.SetupRoutes() srv.SetupRoutes()
// The start hook's context ends when the hook // A closed stdout or stderr must not end the process.
// returns; serving must outlive it. signal.Ignore(syscall.SIGPIPE)
go srv.serve() //nolint:contextcheck,gosec // G118
return nil return srv.startHTTP(ctx)
}, },
OnStop: func(_ context.Context) error { OnStop: func(ctx context.Context) error {
srv.cleanShutdown(ctx)
return nil return nil
}, },
}) })
@@ -129,44 +129,15 @@ func (srv *Server) enableSentry() {
srv.sentryEnabled = true srv.sentryEnabled = true
} }
func (srv *Server) serve() int {
srv.ctx, srv.cancelFunc = context.WithCancel(
context.Background(),
)
go func() {
sigCh := make(chan os.Signal, 1)
signal.Ignore(syscall.SIGPIPE)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
sig := <-sigCh
srv.log.Info("signal received", "signal", sig)
if srv.cancelFunc != nil {
srv.cancelFunc()
}
}()
go srv.serveUntilShutdown()
<-srv.ctx.Done()
srv.cleanShutdown()
return srv.exitCode
}
func (srv *Server) cleanupForExit() { func (srv *Server) cleanupForExit() {
srv.log.Info("cleaning up") srv.log.Info("cleaning up")
} }
func (srv *Server) cleanShutdown() { // cleanShutdown stops the HTTP server, waiting up to
srv.exitCode = 0 // shutdownTimeout for the requests in progress to finish.
func (srv *Server) cleanShutdown(ctx context.Context) {
ctxShutdown, shutdownCancel := context.WithTimeout( ctxShutdown, shutdownCancel := context.WithTimeout(
context.Background(), shutdownTimeout, ctx, shutdownTimeout,
) )
err := srv.httpServer.Shutdown(ctxShutdown) err := srv.httpServer.Shutdown(ctxShutdown)
@@ -191,11 +162,23 @@ func (srv *Server) configure() {
// Server configuration placeholder. // Server configuration placeholder.
} }
func (srv *Server) serveUntilShutdown() { // startHTTP binds the HTTP listen address, so that a port
// that is taken fails startup, then serves the HTTP server
// from a new goroutine. srv.httpServer is set before that
// goroutine starts and never changes, so it and cleanShutdown
// read it without a lock.
func (srv *Server) startHTTP(ctx context.Context) error {
listenAddr := fmt.Sprintf( listenAddr := fmt.Sprintf(
":%d", srv.params.Config.Port, ":%d", srv.params.Config.Port,
) )
var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(ctx, "tcp", listenAddr)
if err != nil {
return fmt.Errorf("http listen: %w", err)
}
srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields
Addr: listenAddr, Addr: listenAddr,
ReadTimeout: httpReadTimeout, ReadTimeout: httpReadTimeout,
@@ -204,16 +187,30 @@ func (srv *Server) serveUntilShutdown() {
Handler: srv, Handler: srv,
} }
go func() {
srv.serveUntilShutdown(listener)
}()
return nil
}
// serveUntilShutdown serves the HTTP server on listener until
// cleanShutdown stops it. If serving fails before then, it
// stops the app with exit code 1.
func (srv *Server) serveUntilShutdown(listener net.Listener) {
srv.log.Info( srv.log.Info(
"http begin listen", "listenaddr", listenAddr, "http begin listen", "listenaddr", srv.httpServer.Addr,
) )
err := srv.httpServer.ListenAndServe() err := srv.httpServer.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) { if errors.Is(err, http.ErrServerClosed) {
srv.log.Error("listen error", "error", err) return
}
if srv.cancelFunc != nil { srv.log.Error("listen error", "error", err)
srv.cancelFunc()
} err = srv.params.Shutdowner.Shutdown(fx.ExitCode(1))
if err != nil {
srv.log.Error("shutdown failed", "error", err)
} }
} }
+358
View File
@@ -0,0 +1,358 @@
// Tests use a global viper instance for configuration,
// making parallel execution unsafe.
//
//nolint:paralleltest
package server_test
import (
"crypto/rand"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"testing"
"time"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/handlers"
"sneak.berlin/go/neoirc/internal/healthcheck"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/middleware"
"sneak.berlin/go/neoirc/internal/server"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/internal/stats"
)
const (
testTimeout = 5 * time.Second
// checkStep is how often waitUntil checks again.
checkStep = 10 * time.Millisecond
// stopHoldTime is how long a test waits to see that the
// app has not stopped.
stopHoldTime = 200 * time.Millisecond
)
// TestStopWaitsForRequestInProgress checks that stopping the
// app, which runs the stop hook server.New registers, waits
// for a long poll the HTTP server is serving to finish, and
// that the poll gets its response.
func TestStopWaitsForRequestInProgress(t *testing.T) {
port := freePort(t)
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
app, brk, database := startApp(t, port)
sessionID, _, token, err := database.CreateSession(
t.Context(), "poller", "poller", "", "",
)
if err != nil {
t.Fatalf("create session: %v", err)
}
waitUntil(t, "the HTTP server to listen", func() bool {
return canConnect(t, addr)
})
statuses := make(chan int, 1)
go func() {
statuses <- getAuthenticated(
t, "http://"+addr+"/api/v1/messages?timeout=30", token,
)
}()
waitUntil(t, "the long poll to wait for a message", func() bool {
return brk.ListenerCount(sessionID) == 1
})
var stopErr error
stopped := make(chan struct{})
go func() {
stopErr = app.Stop(t.Context())
close(stopped)
}()
select {
case <-stopped:
t.Error("the app stopped while a request was in progress")
case <-time.After(stopHoldTime):
}
brk.Notify(sessionID)
status := <-statuses
if status != http.StatusOK {
t.Errorf("status %d, want %d", status, http.StatusOK)
}
select {
case <-stopped:
if stopErr != nil {
t.Errorf("stop: %v", stopErr)
}
case <-time.After(testTimeout):
t.Fatal("the app did not stop after the request finished")
}
}
// TestStartFailsWhenHTTPPortIsTaken checks that starting the
// app, which runs the start hook server.New registers,
// returns an error naming the HTTP listen address when
// something else is already listening on its port.
func TestStartFailsWhenHTTPPortIsTaken(t *testing.T) {
var listenConfig net.ListenConfig
taken, err := listenConfig.Listen(t.Context(), "tcp", ":0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = taken.Close() })
tcpAddr, ok := taken.Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("listener address %v is not TCP", taken.Addr())
}
app, _, _ := newApp(t, tcpAddr.Port)
err = app.Start(t.Context())
if err == nil {
app.RequireStop()
t.Fatal("the app started while its HTTP port was taken")
}
listenAddr := fmt.Sprintf(":%d", tcpAddr.Port)
if !strings.Contains(err.Error(), listenAddr) {
t.Errorf("start error %q does not name %s", err, listenAddr)
}
}
// TestServeFailureStopsApp checks that when serving HTTP fails
// after the start hook has bound the listen address, the
// server stops the app with exit code 1.
func TestServeFailureStopsApp(t *testing.T) {
var srv *server.Server
app, _, _ := startApp(t, freePort(t), fx.Populate(&srv))
shutdowns := app.Wait()
srv.ServeUntilShutdown(closedListener(t))
select {
case shutdown := <-shutdowns:
if shutdown.ExitCode != 1 {
t.Errorf("exit code %d, want 1", shutdown.ExitCode)
}
default:
t.Error("the server did not stop the app")
}
}
// TestServeAfterStopDoesNotStopApp checks that serving HTTP
// after the app has stopped, which ends at once because the
// stop hook has shut the HTTP server down, does not stop the
// app. Serving ends the same way on every normal stop.
func TestServeAfterStopDoesNotStopApp(t *testing.T) {
var srv *server.Server
app, _, _ := startApp(t, freePort(t), fx.Populate(&srv))
shutdowns := app.Wait()
app.RequireStop()
srv.ServeUntilShutdown(closedListener(t))
select {
case shutdown := <-shutdowns:
t.Errorf(
"the server stopped the app with exit code %d",
shutdown.ExitCode,
)
default:
}
}
// startApp starts the app newApp builds, with the HTTP server
// listening on port.
func startApp(
t *testing.T,
port int,
options ...fx.Option,
) (*fxtest.App, *broker.Broker, *db.Database) {
t.Helper()
app, brk, database := newApp(t, port, options...)
app.RequireStart()
// Stops the app if the test ends before stopping it.
t.Cleanup(app.RequireStop)
return app, brk, database
}
// newApp builds, without starting it, an app that runs the
// server with the dependencies cmd/neoircd gives it, on a
// fresh in-memory database, with the HTTP port set to port.
// options are added to the app, for a test that needs more
// from it than the broker and the database.
func newApp(
t *testing.T,
port int,
options ...fx.Option,
) (*fxtest.App, *broker.Broker, *db.Database) {
t.Helper()
var (
brk *broker.Broker
database *db.Database
)
app := fxtest.New(t,
fx.Supply(&globals.Globals{
Appname: "neoirc-test",
Version: "test",
StartTime: time.Now(),
}),
fx.Provide(
broker.New,
config.New,
db.New,
handlers.New,
healthcheck.New,
logger.New,
middleware.New,
server.New,
service.New,
stats.New,
),
fx.Decorate(func(cfg *config.Config) *config.Config {
// A random name, so that no other test can share it.
cfg.DBURL = fmt.Sprintf(
"file:%s?mode=memory&cache=shared", rand.Text(),
)
cfg.Port = port
return cfg
}),
fx.Invoke(func(*server.Server) {}),
fx.Populate(&brk, &database),
fx.Options(options...),
)
return app, brk, database
}
// freePort returns a TCP port that nothing was listening on a
// moment ago.
func freePort(t *testing.T) int {
t.Helper()
listener := closedListener(t)
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("listener address %v is not TCP", listener.Addr())
}
return tcpAddr.Port
}
// closedListener returns a TCP listener that has already been
// closed, so that accepting a connection on it fails.
func closedListener(t *testing.T) net.Listener {
t.Helper()
var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(
t.Context(), "tcp", "127.0.0.1:0",
)
if err != nil {
t.Fatalf("listen: %v", err)
}
err = listener.Close()
if err != nil {
t.Fatalf("close listener: %v", err)
}
return listener
}
// canConnect reports whether addr accepts a TCP connection.
func canConnect(t *testing.T, addr string) bool {
t.Helper()
var dialer net.Dialer
conn, err := dialer.DialContext(t.Context(), "tcp", addr)
if err != nil {
return false
}
_ = conn.Close()
return true
}
// waitUntil calls done every checkStep until it returns true,
// and fails the test if it does not within testTimeout.
func waitUntil(t *testing.T, what string, done func() bool) {
t.Helper()
deadline := time.Now().Add(testTimeout)
for !done() {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %s", what)
}
time.Sleep(checkStep)
}
}
// getAuthenticated sends a GET request to url as the session
// whose auth token is token, and returns the response status,
// or 0 if there was no response.
func getAuthenticated(t *testing.T, url, token string) int {
t.Helper()
request, err := http.NewRequestWithContext(
t.Context(), http.MethodGet, url, nil,
)
if err != nil {
t.Errorf("new request: %v", err)
return 0
}
request.Header.Set("Cookie", "neoirc_auth="+token)
client := http.Client{Timeout: testTimeout}
response, err := client.Do(request)
if err != nil {
t.Errorf("get %s: %v", url, err)
return 0
}
_ = response.Body.Close()
return response.StatusCode
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+29 -22
View File
@@ -2,26 +2,33 @@ package irc
// IRC command names (RFC 1459 / RFC 2812). // IRC command names (RFC 1459 / RFC 2812).
const ( const (
CmdAway = "AWAY" CmdAdmin = "ADMIN"
CmdInvite = "INVITE" CmdAway = "AWAY"
CmdJoin = "JOIN" CmdInfo = "INFO"
CmdKick = "KICK" CmdInvite = "INVITE"
CmdList = "LIST" CmdJoin = "JOIN"
CmdLusers = "LUSERS" CmdKick = "KICK"
CmdMode = "MODE" CmdKill = "KILL"
CmdMotd = "MOTD" CmdList = "LIST"
CmdNames = "NAMES" CmdLusers = "LUSERS"
CmdNick = "NICK" CmdMode = "MODE"
CmdNotice = "NOTICE" CmdMotd = "MOTD"
CmdOper = "OPER" CmdNames = "NAMES"
CmdPass = "PASS" CmdNick = "NICK"
CmdPart = "PART" CmdNotice = "NOTICE"
CmdPing = "PING" CmdOper = "OPER"
CmdPong = "PONG" CmdPass = "PASS"
CmdPrivmsg = "PRIVMSG" CmdPart = "PART"
CmdQuit = "QUIT" CmdPing = "PING"
CmdTopic = "TOPIC" CmdPong = "PONG"
CmdUser = "USER" CmdPrivmsg = "PRIVMSG"
CmdWho = "WHO" CmdQuit = "QUIT"
CmdWhois = "WHOIS" CmdTime = "TIME"
CmdTopic = "TOPIC"
CmdUser = "USER"
CmdUserhost = "USERHOST"
CmdVersion = "VERSION"
CmdWallops = "WALLOPS"
CmdWho = "WHO"
CmdWhois = "WHOIS"
) )
+114 -63
View File
@@ -58,13 +58,13 @@ async function mintHashcash(bits, resource) {
} }
} }
// Requests are authenticated by the auth cookie, which the browser
// sends on its own.
function api(path, opts = {}) { function api(path, opts = {}) {
const token = localStorage.getItem("neoirc_token");
const headers = { const headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
...(opts.headers || {}), ...(opts.headers || {}),
}; };
if (token) headers["Authorization"] = `Bearer ${token}`;
const { signal, ...rest } = opts; const { signal, ...rest } = opts;
return fetch(API + path, { ...rest, headers, signal }).then(async (r) => { return fetch(API + path, { ...rest, headers, signal }).then(async (r) => {
@@ -122,12 +122,11 @@ function LoginScreen({ onLogin }) {
if (s.name) hashcashResourceRef.current = s.name; if (s.name) hashcashResourceRef.current = s.name;
}) })
.catch(() => {}); .catch(() => {});
const saved = localStorage.getItem("neoirc_token"); // Resume the session if the browser still holds a valid auth
if (saved) { // cookie; a 401 leaves the login form up.
api("/state?initChannelState=1") api("/state?initChannelState=1")
.then((u) => onLogin(u.nick, true)) .then((u) => onLogin(u.nick, true))
.catch(() => localStorage.removeItem("neoirc_token")); .catch(() => {});
}
inputRef.current?.focus(); inputRef.current?.focus();
}, []); }, []);
@@ -152,7 +151,6 @@ function LoginScreen({ onLogin }) {
method: "POST", method: "POST",
body: JSON.stringify(reqBody), body: JSON.stringify(reqBody),
}); });
localStorage.setItem("neoirc_token", res.token);
onLogin(res.nick); onLogin(res.nick);
} catch (err) { } catch (err) {
setError(err.data?.error || "Connection failed"); setError(err.data?.error || "Connection failed");
@@ -227,10 +225,9 @@ function UserList({ members, onNickClick }) {
const regular = []; const regular = [];
for (const m of members) { for (const m of members) {
const mode = m.mode || ""; if (m.isOperator) {
if (mode === "o") {
ops.push(m); ops.push(m);
} else if (mode === "v") { } else if (m.isVoiced) {
voiced.push(m); voiced.push(m);
} else { } else {
regular.push(m); regular.push(m);
@@ -303,11 +300,14 @@ function App() {
nickRef.current = nick; nickRef.current = nick;
}, [nick]); }, [nick]);
// Persist joined channels. // Persist joined channels, once logged in: before then only the
// Server tab exists, and writing would erase the saved channels
// that onLogin rejoins.
useEffect(() => { useEffect(() => {
if (!loggedIn) return;
const channels = tabs.filter((t) => t.type === "channel").map((t) => t.name); const channels = tabs.filter((t) => t.type === "channel").map((t) => t.name);
localStorage.setItem("neoirc_channels", JSON.stringify(channels)); localStorage.setItem("neoirc_channels", JSON.stringify(channels));
}, [tabs]); }, [loggedIn, tabs]);
// Clear unread on tab switch. // Clear unread on tab switch.
useEffect(() => { useEffect(() => {
@@ -366,7 +366,9 @@ function App() {
command: msg.command, command: msg.command,
}; };
switch (msg.command) { // A numeric reply carries its number in `code` and its name in
// `command`; other messages have no `code`.
switch (msg.code || msg.command) {
case "PRIVMSG": case "PRIVMSG":
case "NOTICE": { case "NOTICE": {
let text = body; let text = body;
@@ -464,8 +466,18 @@ function App() {
break; break;
} }
case "353": { case "MODE": {
// RPL_NAMREPLY — parse mode prefixes from names list. // The body is the change, with its value where it has one:
// ["+o bob"], ["+m"], ["+k", "secretpass"].
const modes = Array.isArray(msg.body) ? msg.body.join(" ") : "";
const text = `${msg.from} sets mode ${modes} on ${msg.to}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
break;
}
case 353: {
// RPL_NAMREPLY — each name is a hostmask (nick!user@host),
// prefixed with @ for an operator or + for a voiced member.
if ( if (
Array.isArray(msg.params) && Array.isArray(msg.params) &&
msg.params.length >= 2 && msg.params.length >= 2 &&
@@ -477,21 +489,18 @@ function App() {
: String(msg.body); : String(msg.body);
const names = namesStr.split(/\s+/).filter(Boolean); const names = namesStr.split(/\s+/).filter(Boolean);
const parsed = names.map((n) => { const parsed = names.map((n) => {
if (n.startsWith("@")) { const isOperator = n.startsWith("@");
return { nick: n.slice(1), mode: "o" }; const isVoiced = n.startsWith("+");
} const hostmask = isOperator || isVoiced ? n.slice(1) : n;
if (n.startsWith("+")) {
return { nick: n.slice(1), mode: "v" };
}
return { nick: n, mode: "" }; return { nick: hostmask.split("!")[0], isOperator, isVoiced };
}); });
setMembers((prev) => ({ ...prev, [channel]: parsed })); setMembers((prev) => ({ ...prev, [channel]: parsed }));
} }
break; break;
} }
case "332": { case 332: {
// RPL_TOPIC — set topic from server. // RPL_TOPIC — set topic from server.
if (Array.isArray(msg.params) && msg.params.length >= 1) { if (Array.isArray(msg.params) && msg.params.length >= 1) {
const channel = msg.params[0]; const channel = msg.params[0];
@@ -503,7 +512,7 @@ function App() {
break; break;
} }
case "322": { case 322: {
// RPL_LIST — channel, member count, topic. // RPL_LIST — channel, member count, topic.
if (Array.isArray(msg.params) && msg.params.length >= 2) { if (Array.isArray(msg.params) && msg.params.length >= 2) {
const chName = msg.params[0]; const chName = msg.params[0];
@@ -518,7 +527,7 @@ function App() {
break; break;
} }
case "323": case 323:
addMessage("Server", { addMessage("Server", {
...base, ...base,
text: body || "End of channel list", text: body || "End of channel list",
@@ -526,7 +535,7 @@ function App() {
}); });
break; break;
case "352": { case 352: {
// RPL_WHOREPLY — channel, user, host, server, nick, flags. // RPL_WHOREPLY — channel, user, host, server, nick, flags.
if (Array.isArray(msg.params) && msg.params.length >= 5) { if (Array.isArray(msg.params) && msg.params.length >= 5) {
const whoCh = msg.params[0]; const whoCh = msg.params[0];
@@ -541,7 +550,7 @@ function App() {
break; break;
} }
case "315": case 315:
addMessage("Server", { addMessage("Server", {
...base, ...base,
text: body || "End of /WHO list", text: body || "End of /WHO list",
@@ -549,20 +558,20 @@ function App() {
}); });
break; break;
case "311": { case 311: {
// RPL_WHOISUSER — nick, user, host, *, realname. // RPL_WHOISUSER — params nick, user, host, *; body realname.
if (Array.isArray(msg.params) && msg.params.length >= 1) { if (Array.isArray(msg.params) && msg.params.length >= 3) {
const wiNick = msg.params[0]; const [wiNick, wiUser, wiHost] = msg.params;
addMessage("Server", { addMessage("Server", {
...base, ...base,
text: `${wiNick} (${body})`, text: `${wiNick} is ${wiUser}@${wiHost} (${body})`,
system: true, system: true,
}); });
} }
break; break;
} }
case "312": { case 312: {
// RPL_WHOISSERVER — nick, server, server info. // RPL_WHOISSERVER — nick, server, server info.
if (Array.isArray(msg.params) && msg.params.length >= 2) { if (Array.isArray(msg.params) && msg.params.length >= 2) {
const wiNick = msg.params[0]; const wiNick = msg.params[0];
@@ -576,7 +585,33 @@ function App() {
break; break;
} }
case "319": { case 313: {
// RPL_WHOISOPERATOR — nick; body "is an IRC operator".
if (Array.isArray(msg.params) && msg.params.length >= 1) {
addMessage("Server", {
...base,
text: `${msg.params[0]} ${body}`,
system: true,
});
}
break;
}
case 317: {
// RPL_WHOISIDLE — nick, idle seconds, signon time (Unix).
if (Array.isArray(msg.params) && msg.params.length >= 3) {
const [wiNick, idle, signon] = msg.params;
const signedOn = new Date(Number(signon) * 1000).toLocaleString();
addMessage("Server", {
...base,
text: `${wiNick} has been idle ${idle}s, signed on ${signedOn}`,
system: true,
});
}
break;
}
case 319: {
// RPL_WHOISCHANNELS — nick, channels. // RPL_WHOISCHANNELS — nick, channels.
if (Array.isArray(msg.params) && msg.params.length >= 1) { if (Array.isArray(msg.params) && msg.params.length >= 1) {
const wiNick = msg.params[0]; const wiNick = msg.params[0];
@@ -589,7 +624,7 @@ function App() {
break; break;
} }
case "318": case 318:
addMessage("Server", { addMessage("Server", {
...base, ...base,
text: body || "End of /WHOIS list", text: body || "End of /WHOIS list",
@@ -597,9 +632,21 @@ function App() {
}); });
break; break;
case "375": case 324: {
case "372": // RPL_CHANNELMODEIS — channel, mode string.
case "376": if (Array.isArray(msg.params) && msg.params.length >= 2) {
addMessage("Server", {
...base,
text: `${msg.params[0]} modes: ${msg.params[1]}`,
system: true,
});
}
break;
}
case 375:
case 372:
case 376:
addMessage("Server", { ...base, text: body, system: true }); addMessage("Server", { ...base, text: body, system: true });
break; break;
@@ -637,6 +684,14 @@ function App() {
} catch (err) { } catch (err) {
if (!alive) break; if (!alive) break;
if (err.name === "AbortError") continue; if (err.name === "AbortError") continue;
// 401: the session is gone (QUIT from another client, or
// expiry). Reloading drops this page's state and brings
// back the login screen.
if (err.status === 401) {
window.location.reload();
return;
}
setConnected(false); setConnected(false);
await new Promise((r) => setTimeout(r, RECONNECT_DELAY)); await new Promise((r) => setTimeout(r, RECONNECT_DELAY));
} }
@@ -716,6 +771,11 @@ function App() {
const onLogin = useCallback( const onLogin = useCallback(
async (userNick, isResumed) => { async (userNick, isResumed) => {
// Read before setLoggedIn(true): from then on the effect under
// "Persist joined channels" writes the open channel tabs over it.
const saved = JSON.parse(
localStorage.getItem("neoirc_channels") || "[]",
);
setNick(userNick); setNick(userNick);
setLoggedIn(true); setLoggedIn(true);
addSystemMessage("Server", `Connected as ${userNick}`); addSystemMessage("Server", `Connected as ${userNick}`);
@@ -740,9 +800,6 @@ function App() {
} }
// Fresh session — join any previously saved channels. // Fresh session — join any previously saved channels.
const saved = JSON.parse(
localStorage.getItem("neoirc_channels") || "[]",
);
for (const ch of saved) { for (const ch of saved) {
try { try {
await api("/messages", { await api("/messages", {
@@ -972,27 +1029,22 @@ function App() {
break; break;
} }
// With no arguments MODE is a query. A change is one sign and
// letter, then its value where it takes one: +m, +o nick.
const modeArgs = parts.slice(1); const modeArgs = parts.slice(1);
if (modeArgs.length > 0) { const body =
try { modeArgs.length > 0
await api("/messages", { ? { command: "MODE", to: tab.name, body: modeArgs }
method: "POST", : { command: "MODE", to: tab.name };
body: JSON.stringify({ try {
command: "MODE", await api("/messages", {
to: tab.name, method: "POST",
params: modeArgs, body: JSON.stringify(body),
}), });
}); } catch (err) {
} catch (err) {
addSystemMessage(
"Server",
`Mode change failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage( addSystemMessage(
"Server", "Server",
"Usage: /mode <+/-mode> [params]", `MODE failed: ${err.data?.error || "error"}`,
); );
} }
@@ -1009,7 +1061,6 @@ function App() {
} catch (e) { } catch (e) {
// Ignore. // Ignore.
} }
localStorage.removeItem("neoirc_token");
localStorage.removeItem("neoirc_channels"); localStorage.removeItem("neoirc_channels");
window.location.reload(); window.location.reload();
@@ -1128,7 +1179,7 @@ function App() {
" /me action — Send an action", " /me action — Send an action",
" /nick newnick — Change your nickname", " /nick newnick — Change your nickname",
" /topic [text] — View or set channel topic", " /topic [text] — View or set channel topic",
" /mode +/-flags — Set channel modes", " /mode [+/-flag [arg]] — View or set channel modes",
" /motd — Display the message of the day", " /motd — Display the message of the day",
" /list — List all channels", " /list — List all channels",
" /who [#channel] — List users in a channel", " /who [#channel] — List users in a channel",