fix: KILL actually disconnects the victim; unify HTTP/IRC divergences
All checks were successful
check / check (push) Successful in 1m12s
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:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user