fix: KILL actually disconnects the victim; unify HTTP/IRC divergences
All checks were successful
check / check (push) Successful in 1m12s

Addresses the 2026-08-10 FAIL review (findings 1-7, 9, 10).

KILL never terminated the victim's connection on either transport: both
paths called BroadcastQuit, which deletes the session row and tells the
victim's peers it quit, but leaves the victim holding a socket that looks
alive and silently delivers nothing while its nick is freed for reuse.
Service now owns a session-ID keyed registry of live wire connections that
ircserver populates at registration, and both KILL paths go through the new
Service.KillSession, which broadcasts the QUIT and then sends the victim a
KILL and ERROR :Closing Link before closing its socket. The victim's relay
goroutine is cancelled and its cleanup no longer re-broadcasts a QUIT for
an already-deleted session. TestIntegrationKill now asserts the victim
reads to EOF and is gone from NAMES and WHO, not just that an observer saw
the QUIT relay.

HTTP MODE <othernick> with no body answered with the requester's own modes,
because the target check sat inside the mode-change branch. The check is
hoisted above the query/change split, and both transports now compare nicks
with EqualFold since IRC nicks are case-insensitive.

Service.QueryUserMode returned "+" for a database failure, making an
unreadable mode indistinguishable from an unset one; it now returns an
error, and both callers surface it. db.GetUserhostInfo likewise treated
every scan error as "nick not found"; only sql.ErrNoRows is skipped now.

The four new unsynchronized c.nick reads this branch introduced are read
through currentNick() under c.mu, and c.closed is now guarded everywhere
because KILL writes it from another client's goroutine. Conn.send takes a
write mutex, as a connection is now written to by three goroutines.

server.Server.Run was left with no in-tree callers when its body was
inlined into the fx OnStart hook; it is deleted rather than left to drift.

INFO and VERSION had two implementations that had already diverged: the
version string is now Service.ServerVersion and the INFO body is
Service.InfoLines, used verbatim by both transports. The ctx parameters on
handleVersion/handleAdmin/handleInfo/handleTime existed only to be
discarded and are gone.
This commit is contained in:
clawbot
2026-09-03 15:17:43 +00:00
parent f24e33a310
commit c20ad88dfe
13 changed files with 600 additions and 158 deletions

View File

@@ -7,6 +7,7 @@ import (
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
@@ -2509,7 +2510,9 @@ type UserhostInfo struct {
}
// GetUserhostInfo returns USERHOST info for the given
// nicks. Only nicks that exist are returned.
// nicks. Nicks with no session are omitted from the
// result; any other database failure is returned, because
// an unreadable row is not the same as an absent one.
func (database *Database) GetUserhostInfo(
ctx context.Context,
nicks []string,
@@ -2534,7 +2537,13 @@ func (database *Database) GetUserhostInfo(
&info.IsOper, &info.AwayMessage,
)
if err != nil {
continue // nick not found, skip
if errors.Is(err, sql.ErrNoRows) {
continue // nick not online
}
return nil, fmt.Errorf(
"userhost lookup %q: %w", nick, err,
)
}
results = append(results, info)

View File

@@ -214,6 +214,7 @@ func newTestHandlers(
Config: cfg,
Database: database,
Broker: brk,
Globals: globs,
})
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct

View File

@@ -184,7 +184,7 @@ func (hdlr *Handlers) handleVersion(
) {
ctx := request.Context()
srvName := hdlr.serverName()
version := hdlr.serverVersion()
version := hdlr.svc.ServerVersion()
// 351 RPL_VERSION
hdlr.enqueueNumeric(
@@ -250,18 +250,8 @@ func (hdlr *Handlers) handleInfo(
nick string,
) {
ctx := request.Context()
version := hdlr.serverVersion()
infoLines := []string{
"neoirc — IRC semantics over HTTP",
"Version: " + version,
"Written in Go",
"Started: " +
hdlr.params.Globals.StartTime.
Format(time.RFC1123),
}
for _, line := range infoLines {
for _, line := range hdlr.svc.InfoLines() {
// 371 RPL_INFO
hdlr.enqueueNumeric(
ctx, clientID, irc.RplInfo, nick, nil,
@@ -379,9 +369,11 @@ func (hdlr *Handlers) handleKill(
quitReason := "Killed (" + nick + " (" + reason + "))"
hdlr.svc.BroadcastQuit(
request.Context(), targetSID,
targetNick, quitReason,
// KillSession broadcasts the QUIT, deletes the session
// and disconnects the victim's wire connection if it
// holds one.
hdlr.svc.KillSession(
ctx, targetSID, targetNick, quitReason,
)
hdlr.respondJSON(writer, request,
@@ -484,61 +476,47 @@ func (hdlr *Handlers) handleUserMode(
) {
ctx := request.Context()
// Users can only query or change their own modes. The
// check is above the query/change split so that both
// forms are rejected, and uses EqualFold because IRC
// nicks are case-insensitive — matching the wire path
// in ircserver.handleUserMode.
if target != "" && !strings.EqualFold(target, nick) {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUsersDoNotMatch, nick, nil,
"Can't change mode for other users",
)
return
}
lines := bodyLines()
// Mode change requested.
if len(lines) > 0 {
// Users can only change their own modes.
if target != nick && target != "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUsersDoNotMatch, nick, nil,
"Can't change mode for other users",
)
return
}
newModes, err := hdlr.svc.ApplyUserMode(
ctx, sessionID, lines[0],
hdlr.changeUserMode(
writer, request,
sessionID, clientID, nick, lines[0],
)
if err != nil {
var ircErr *service.IRCError
if errors.As(err, &ircErr) {
hdlr.respondIRCError(
writer, request,
clientID, sessionID,
ircErr.Code, nick, ircErr.Params,
ircErr.Message,
)
return
}
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUmodeIs, nick, nil,
newModes,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
return
}
// Mode query — delegate to shared service.
modeStr := hdlr.svc.QueryUserMode(ctx, sessionID)
modeStr, err := hdlr.svc.QueryUserMode(ctx, sessionID)
if err != nil {
hdlr.log.Error(
"query user mode failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUmodeIs, nick, nil,
@@ -549,3 +527,50 @@ func (hdlr *Handlers) handleUserMode(
map[string]string{"status": "ok"},
http.StatusOK)
}
// changeUserMode applies a mode string to the caller's own
// session. The caller has already verified that the target
// nick is the caller's own.
func (hdlr *Handlers) changeUserMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, modeStr string,
) {
ctx := request.Context()
newModes, err := hdlr.svc.ApplyUserMode(
ctx, sessionID, modeStr,
)
if err != nil {
var ircErr *service.IRCError
if errors.As(err, &ircErr) {
hdlr.respondIRCError(
writer, request,
clientID, sessionID,
ircErr.Code, nick, ircErr.Params,
ircErr.Message,
)
return
}
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUmodeIs, nick, nil,
newModes,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}

View File

@@ -959,6 +959,95 @@ func TestUserModeCannotChangeOtherUser(t *testing.T) {
}
}
// TestUserModeCannotQueryOtherUser covers the MODE query
// form (no body). Without a body there is no mode change
// to reject, so a missing target check here silently
// answers with the *requester's* own modes.
func TestUserModeCannotQueryOtherUser(t *testing.T) {
tserver := newTestServer(t)
// Give the other user a mode the querier does not have,
// so leaking their modes would be visible.
otherToken := tserver.createSession("target")
_, otherLast := tserver.pollMessages(otherToken, 0)
tserver.sendCommand(otherToken, map[string]any{
commandKey: "MODE",
toKey: "target",
bodyKey: []string{"+w"},
})
tserver.pollMessages(otherToken, otherLast)
token := tserver.createSession("querier")
_, lastID := tserver.pollMessages(token, 0)
// Query another user's modes — no body.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "target",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 502 ERR_USERSDONTMATCH.
if !findNumeric(msgs, "502") {
t.Fatalf(
"expected ERR_USERSDONTMATCH (502), got %v",
msgs,
)
}
// And definitely not a mode reply.
if findNumeric(msgs, "221") {
t.Fatalf(
"MODE query for another user leaked "+
"RPL_UMODEIS (221): %v",
msgs,
)
}
}
// TestUserModeOwnNickIsCaseInsensitive covers the same-nick
// comparison. IRC nicks are case-insensitive, so a user
// must be able to address their own modes in any case —
// and the wire path already allows it.
func TestUserModeOwnNickIsCaseInsensitive(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("mixedcase")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "MixedCase",
bodyKey: []string{"+w"},
})
msgs, _ := tserver.pollMessages(token, lastID)
if findNumeric(msgs, "502") {
t.Fatalf(
"own nick in different case rejected with "+
"ERR_USERSDONTMATCH (502): %v",
msgs,
)
}
msg := findNumericWithParams(msgs, "221")
if msg == nil {
t.Fatalf(
"expected RPL_UMODEIS (221), got %v", msgs,
)
}
body := getNumericBody(msg)
if !strings.Contains(body, "w") {
t.Fatalf(
"expected 'w' to be set, got %q", body,
)
}
}
// getNumericBody extracts the body text from a numeric
// message. The body is stored as a JSON array; this
// returns the first element.

View File

@@ -8,29 +8,10 @@ import (
"strings"
"time"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc"
)
// versionString returns the server version for IRC
// responses, falling back to "neoirc-dev" when globals
// are not set (e.g. during tests).
func versionString() string {
name := globals.Appname
ver := globals.Version
if name == "" {
name = "neoirc"
}
if ver == "" {
ver = "dev"
}
return name + "-" + ver
}
// sendIRCError maps a service.IRCError to an IRC numeric
// reply on the wire.
func (c *Conn) sendIRCError(err error) {
@@ -368,7 +349,10 @@ func (c *Conn) handleQuit(msg *Message) {
c.send("ERROR :Closing Link: " + c.hostname +
" (Quit: " + reason + ")")
c.mu.Lock()
c.closed = true
c.mu.Unlock()
}
// handleTopic gets or sets a channel topic via the shared
@@ -719,7 +703,7 @@ func (c *Conn) handleUserMode(
) {
target := msg.Params[0]
if !strings.EqualFold(target, c.nick) {
if !strings.EqualFold(target, c.currentNick()) {
c.sendNumeric(
irc.ErrUsersDoNotMatch,
"Can't change mode for other users",
@@ -730,7 +714,21 @@ func (c *Conn) handleUserMode(
// Mode query (no mode string).
if len(msg.Params) < 2 { //nolint:mnd
modes := c.svc.QueryUserMode(ctx, c.sessionID)
modes, err := c.svc.QueryUserMode(
ctx, c.sessionID,
)
if err != nil {
c.log.Error(
"query user mode failed", "error", err,
)
c.sendNumeric(
irc.ErrUmodeUnknownFlag,
"Unable to read user modes",
)
return
}
c.sendNumeric(irc.RplUmodeIs, modes)
return
@@ -1349,22 +1347,16 @@ func (c *Conn) handleUserhost(
}
// handleVersion replies with the server version string.
func (c *Conn) handleVersion(ctx context.Context) {
_ = ctx
version := versionString()
func (c *Conn) handleVersion() {
c.sendNumeric(
irc.RplVersion,
version+".", c.cfg.ServerName,
c.svc.ServerVersion()+".", c.cfg.ServerName,
"",
)
}
// handleAdmin replies with server admin info.
func (c *Conn) handleAdmin(ctx context.Context) {
_ = ctx
func (c *Conn) handleAdmin() {
srvName := c.cfg.ServerName
c.sendNumeric(
@@ -1389,16 +1381,8 @@ func (c *Conn) handleAdmin(ctx context.Context) {
}
// handleInfo replies with server software info.
func (c *Conn) handleInfo(ctx context.Context) {
_ = ctx
infoLines := []string{
"neoirc — IRC semantics over HTTP",
"Version: " + versionString(),
"Written in Go",
}
for _, line := range infoLines {
func (c *Conn) handleInfo() {
for _, line := range c.svc.InfoLines() {
c.sendNumeric(irc.RplInfo, line)
}
@@ -1409,9 +1393,7 @@ func (c *Conn) handleInfo(ctx context.Context) {
}
// handleTime replies with the server's current time.
func (c *Conn) handleTime(ctx context.Context) {
_ = ctx
func (c *Conn) handleTime() {
srvName := c.cfg.ServerName
c.sendNumeric(
@@ -1455,7 +1437,9 @@ func (c *Conn) handleKillCmd(
reason = msg.Params[1]
}
if targetNick == c.nick {
killerNick := c.currentNick()
if strings.EqualFold(targetNick, killerNick) {
c.sendNumeric(
irc.ErrCantKillServer,
"You cannot KILL yourself",
@@ -1476,9 +1460,12 @@ func (c *Conn) handleKillCmd(
return
}
quitReason := "Killed (" + c.nick + " (" + reason + "))"
quitReason := "Killed (" + killerNick +
" (" + reason + "))"
c.svc.BroadcastQuit(
// KillSession broadcasts the QUIT, deletes the session
// and disconnects the victim's wire connection.
c.svc.KillSession(
ctx, targetSID, targetNick, quitReason,
)
}
@@ -1530,7 +1517,7 @@ func (c *Conn) handleWallopsCmd(
}
_, _, _ = c.svc.FanOut(
ctx, irc.CmdWallops, c.nick, "*",
ctx, irc.CmdWallops, c.currentNick(), "*",
nil, body, nil, wallopsSIDs,
)
}

View File

@@ -46,6 +46,12 @@ type Conn struct {
serverSfx string
commands map[string]cmdHandler
// writeMu serializes writes to conn. A connection is
// written to by its own read loop, by its relay
// goroutine, and — when an operator KILLs it — by
// another client's goroutine.
writeMu sync.Mutex
mu sync.Mutex
nick string
username string
@@ -62,6 +68,7 @@ type Conn struct {
lastQueueID int64
closed bool
killed bool
cancel context.CancelFunc
}
@@ -98,6 +105,47 @@ func newConn(
return conn
}
// Disconnect terminates the connection on behalf of an
// operator KILL issued from either transport. The victim
// is told why, then its socket is closed so that the read
// loop unblocks and serve() returns; without the close the
// victim would keep a socket that looks alive but silently
// delivers nothing. Disconnect is called from the killer's
// goroutine, never the victim's.
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 = "*"
}
c.sendFromServer(irc.CmdKill, nick, reason)
c.send(
"ERROR :Closing Link: " + host +
" (" + reason + ")",
)
// Stop the relay goroutine, which would otherwise keep
// polling a queue belonging to a deleted session.
if c.cancel != nil {
c.cancel()
}
c.conn.Close() //nolint:errcheck,gosec
}
// buildCommandMap returns a map from IRC command strings
// to handler functions.
func (c *Conn) buildCommandMap() map[string]cmdHandler {
@@ -131,10 +179,10 @@ func (c *Conn) buildCommandMap() map[string]cmdHandler {
c.handleCAP(msg)
},
"USERHOST": c.handleUserhost,
irc.CmdVersion: func(ctx context.Context, _ *Message) { c.handleVersion(ctx) },
irc.CmdAdmin: func(ctx context.Context, _ *Message) { c.handleAdmin(ctx) },
irc.CmdInfo: func(ctx context.Context, _ *Message) { c.handleInfo(ctx) },
irc.CmdTime: func(ctx context.Context, _ *Message) { c.handleTime(ctx) },
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.handleKillCmd,
irc.CmdWallops: c.handleWallopsCmd,
}
@@ -185,7 +233,7 @@ func (c *Conn) serve(ctx context.Context) {
c.handleMessage(ctx, msg)
if c.closed {
if c.isClosed() {
return
}
}
@@ -194,22 +242,52 @@ func (c *Conn) serve(ctx context.Context) {
func (c *Conn) cleanup(ctx context.Context) {
c.mu.Lock()
wasRegistered := c.registered
wasKilled := c.killed
sessID := c.sessionID
nick := c.nick
c.closed = true
c.mu.Unlock()
if wasRegistered && sessID > 0 {
c.svc.BroadcastQuit(
ctx, sessID, nick, "Connection closed",
)
c.svc.UnregisterWireConn(sessID, c)
// A KILLed session has already been broadcast and
// deleted by the killer; broadcasting again would
// fan out a QUIT for a session row that is gone.
if !wasKilled {
c.svc.BroadcastQuit(
ctx, sessID, nick, "Connection closed",
)
}
}
c.conn.Close() //nolint:errcheck,gosec
}
// isClosed reports whether the connection has been marked
// for teardown, either by QUIT or by an operator KILL.
func (c *Conn) isClosed() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.closed
}
// currentNick returns the connection's registered nick.
// c.nick is written under c.mu during registration and
// NICK changes, so every read must take the mutex.
func (c *Conn) currentNick() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.nick
}
// send writes a formatted IRC line to the connection.
func (c *Conn) send(line string) {
c.writeMu.Lock()
defer c.writeMu.Unlock()
_ = c.conn.SetWriteDeadline(
time.Now().Add(writeTimeout),
)
@@ -392,7 +470,10 @@ func (c *Conn) completeRegistration(ctx context.Context) {
"failed to create session", "error", err,
)
c.send("ERROR :Internal server error")
c.mu.Lock()
c.closed = true
c.mu.Unlock()
return
}
@@ -403,6 +484,10 @@ func (c *Conn) completeRegistration(ctx context.Context) {
c.registered = true
c.mu.Unlock()
// Make this connection reachable by session ID so that
// KILL from either transport can disconnect it.
c.svc.RegisterWireConn(sessionID, c)
// If PASS was provided before registration, set the
// session password.
if c.passWord != "" && len(c.passWord) >= minPasswordLen {

View File

@@ -4,10 +4,12 @@ import (
"context"
"log/slog"
"net"
"time"
"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/service"
)
@@ -19,8 +21,14 @@ func NewTestServer(
database *db.Database,
brk *broker.Broker,
) *Server {
globs := &globals.Globals{
Appname: "neoirc",
Version: "test",
StartTime: time.Now(),
}
svc := service.NewTestService(
database, brk, cfg, log,
database, brk, cfg, globs, log,
)
return &Server{ //nolint:exhaustruct

View File

@@ -923,7 +923,11 @@ func TestIntegrationTime(t *testing.T) {
}
// TestIntegrationKill verifies the KILL command: oper can
// kill a user, non-oper cannot.
// kill a user, non-oper cannot, and — most importantly —
// that the victim is actually disconnected rather than
// merely announced as having quit.
//
//nolint:funlen // one KILL scenario asserted end to end
func TestIntegrationKill(t *testing.T) {
t.Parallel()
@@ -965,6 +969,24 @@ func TestIntegrationKill(t *testing.T) {
// Oper KILL should succeed.
alice.send("KILL bob :bad behavior")
// The victim must be told why and then disconnected.
// Reading to EOF is the assertion that matters: a KILL
// that only broadcasts a QUIT leaves bob holding a
// socket that looks alive but delivers nothing.
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") &&
@@ -975,6 +997,32 @@ func TestIntegrationKill(t *testing.T) {
"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",
)
// KILL nonexistent nick.
alice.send("KILL nobody123 :gone")

View File

@@ -291,6 +291,36 @@ func (tc *testClient) register(nick string) []string {
})
}
// readUntilClosed reads until the peer closes the
// connection, returning the lines received first. It
// fails the test if the connection is still open when the
// read deadline expires, which is what a KILL that never
// terminates the victim's socket looks like.
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(
"expected EOF on victim socket, got %v "+
"(lines: %v)",
err, lines,
)
}
return lines
}
// assertContains checks that at least one line matches the
// given substring.
func assertContains(
@@ -309,6 +339,27 @@ func assertContains(
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
// RPL_ENDOFNAMES.
func (tc *testClient) joinAndDrain(channel string) {

View File

@@ -92,19 +92,6 @@ func New(
return srv, nil
}
// Run configures the server and begins serving. It blocks
// until shutdown is signalled. Kept for external callers
// that embed the server outside fx. The fx lifecycle now
// performs setup synchronously in OnStart and invokes
// serve directly in a goroutine, so this is only used when
// the server is driven by hand.
func (srv *Server) Run() {
srv.configure()
srv.enableSentry()
srv.SetupRoutes()
srv.serve()
}
// ServeHTTP delegates to the chi router.
func (srv *Server) ServeHTTP(
writer http.ResponseWriter,

View File

@@ -10,11 +10,14 @@ import (
"log/slog"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/fx"
"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/logger"
"sneak.berlin/go/neoirc/pkg/irc"
)
@@ -27,23 +30,41 @@ type Params struct {
Config *config.Config
Database *db.Database
Broker *broker.Broker
Globals *globals.Globals
}
// WireConn is a live client connection that a transport
// registers with the service so that commands such as KILL
// can reach it. The IRC wire server registers one per
// registered connection; the HTTP transport has no
// long-lived socket and registers nothing.
type WireConn interface {
// Disconnect terminates the connection, telling the
// client why before closing the socket.
Disconnect(reason string)
}
// Service provides shared business logic for IRC commands.
type Service struct {
db *db.Database
broker *broker.Broker
config *config.Config
log *slog.Logger
db *db.Database
broker *broker.Broker
config *config.Config
globals *globals.Globals
log *slog.Logger
wireMu sync.Mutex
wireConns map[int64]WireConn
}
// New creates a new Service.
func New(params Params) *Service {
return &Service{
db: params.Database,
broker: params.Broker,
config: params.Config,
log: params.Logger.Get(),
return &Service{ //nolint:exhaustruct // mutex zero value
db: params.Database,
broker: params.Broker,
config: params.Config,
globals: params.Globals,
log: params.Logger.Get(),
wireConns: make(map[int64]WireConn),
}
}
@@ -53,13 +74,105 @@ func NewTestService(
database *db.Database,
brk *broker.Broker,
cfg *config.Config,
globs *globals.Globals,
log *slog.Logger,
) *Service {
return &Service{
db: database,
broker: brk,
config: cfg,
log: log,
return &Service{ //nolint:exhaustruct // mutex zero value
db: database,
broker: brk,
config: cfg,
globals: globs,
log: log,
wireConns: make(map[int64]WireConn),
}
}
// ServerVersion returns the canonical server version string
// used by every transport, e.g. "neoirc-1.2.3". Both the
// IRC wire protocol and the HTTP API must report the same
// string, so this is the only place it is built.
func (s *Service) ServerVersion() string {
name := "neoirc"
ver := "dev"
if s.globals != nil {
if s.globals.Appname != "" {
name = s.globals.Appname
}
if s.globals.Version != "" {
ver = s.globals.Version
}
}
return name + "-" + ver
}
// InfoLines returns the RPL_INFO body. Both transports
// send exactly these lines so that INFO does not diverge
// between the wire protocol and the HTTP API.
func (s *Service) InfoLines() []string {
started := "unknown"
if s.globals != nil && !s.globals.StartTime.IsZero() {
started = s.globals.StartTime.Format(time.RFC1123)
}
return []string{
"neoirc — IRC semantics over HTTP",
"Version: " + s.ServerVersion(),
"Written in Go",
"Started: " + started,
}
}
// RegisterWireConn associates a live wire connection with
// its session ID so that KillSession can reach it.
func (s *Service) RegisterWireConn(
sessionID int64,
conn WireConn,
) {
s.wireMu.Lock()
defer s.wireMu.Unlock()
s.wireConns[sessionID] = conn
}
// UnregisterWireConn removes the association created by
// RegisterWireConn. It is a no-op if the session has
// already been rebound to a different connection.
func (s *Service) UnregisterWireConn(
sessionID int64,
conn WireConn,
) {
s.wireMu.Lock()
defer s.wireMu.Unlock()
if s.wireConns[sessionID] == conn {
delete(s.wireConns, sessionID)
}
}
// KillSession forcibly removes a user from the server: the
// victim's channel peers are told via QUIT, the victim's
// session is deleted, and any live wire connection it holds
// is disconnected. Both the IRC KILL command and the HTTP
// KILL endpoint route through here so the two transports
// cannot diverge.
func (s *Service) KillSession(
ctx context.Context,
sessionID int64,
nick, reason string,
) {
s.BroadcastQuit(ctx, sessionID, nick, reason)
// A session with no registered wire connection (an
// HTTP-only client) has nothing left to disconnect.
s.wireMu.Lock()
conn := s.wireConns[sessionID]
s.wireMu.Unlock()
if conn != nil {
conn.Disconnect(reason)
}
}
@@ -792,26 +905,42 @@ func (s *Service) QueryChannelMode(
}
// QueryUserMode returns the current user mode string for
// the given session (e.g. "+ow", "+w", "+").
// the given session (e.g. "+ow", "+w", "+"). A database
// failure is returned rather than being reported as an
// unset flag: an unreadable mode is not the same as an
// absent one, and reporting "+" for either would tell the
// user they are de-opered when the truth is unknown.
func (s *Service) QueryUserMode(
ctx context.Context,
sessionID int64,
) string {
) (string, error) {
modes := "+"
isOper, err := s.db.IsSessionOper(ctx, sessionID)
if err == nil && isOper {
if err != nil {
return "", fmt.Errorf(
"query oper flag: %w", err,
)
}
if isOper {
modes += "o"
}
isWallops, err := s.db.IsSessionWallops(
ctx, sessionID,
)
if err == nil && isWallops {
if err != nil {
return "", fmt.Errorf(
"query wallops flag: %w", err,
)
}
if isWallops {
modes += "w"
}
return modes
return modes, nil
}
// userModeOp is a single parsed user-mode change collected
@@ -847,7 +976,7 @@ func (s *Service) ApplyUserMode(
}
}
return s.QueryUserMode(ctx, sessionID), nil
return s.QueryUserMode(ctx, sessionID)
}
// parseUserModeString validates and parses a user-mode

View File

@@ -11,6 +11,7 @@ import (
"fmt"
"os"
"testing"
"time"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
@@ -55,9 +56,10 @@ func newTestEnv(t *testing.T) *testEnv {
app := fxtest.New(t,
fx.Provide(
func() *globals.Globals {
return &globals.Globals{ //nolint:exhaustruct
Appname: "neoirc-test",
Version: "test",
return &globals.Globals{
Appname: "neoirc-test",
Version: "test",
StartTime: time.Now(),
}
},
logger.New,
@@ -371,7 +373,11 @@ func TestQueryUserMode(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice")
// Fresh session has no modes.
modes := env.svc.QueryUserMode(ctx, sid)
modes, err := env.svc.QueryUserMode(ctx, sid)
if err != nil {
t.Fatalf("query user mode: %v", err)
}
if modes != "+" {
t.Errorf("expected +, got %s", modes)
}
@@ -379,7 +385,11 @@ func TestQueryUserMode(t *testing.T) {
// Set wallops.
_ = env.db.SetSessionWallops(ctx, sid, true)
modes = env.svc.QueryUserMode(ctx, sid)
modes, err = env.svc.QueryUserMode(ctx, sid)
if err != nil {
t.Fatalf("query user mode: %v", err)
}
if modes != "+w" {
t.Errorf("expected +w, got %s", modes)
}
@@ -387,7 +397,11 @@ func TestQueryUserMode(t *testing.T) {
// Set oper.
_ = env.db.SetSessionOper(ctx, sid, true)
modes = env.svc.QueryUserMode(ctx, sid)
modes, err = env.svc.QueryUserMode(ctx, sid)
if err != nil {
t.Fatalf("query user mode: %v", err)
}
if modes != "+ow" {
t.Errorf("expected +ow, got %s", modes)
}