diff --git a/README.md b/README.md index 7fa7a0b..0944e5a 100644 --- a/README.md +++ b/README.md @@ -2324,6 +2324,15 @@ IRC_LISTEN_ADDR= operator status (`@`). - **Channel modes**: `+m` (moderated), `+t` (topic lock), `+o` (operator), `+v` (voice) +- **User modes**: `+o` (operator, set only via `OPER`), `+w` (receives + `WALLOPS`). `MODE` for any nick other than your own is rejected with + `ERR_USERSDONTMATCH` (502), for both queries and changes. Nick comparison + is case-insensitive. +- **KILL**: an operator's `KILL` broadcasts the victim's `QUIT` to its channel + peers, deletes its session, and then sends the victim a `KILL` and + `ERROR :Closing Link` before closing its socket. This applies to victims on + the IRC listener regardless of whether the `KILL` arrived over IRC or the + HTTP API. ### Bridge to HTTP API diff --git a/internal/db/queries.go b/internal/db/queries.go index 182e50a..d1abc25 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -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) diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index bdfabd5..bf5599c 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -214,6 +214,7 @@ func newTestHandlers( Config: cfg, Database: database, Broker: brk, + Globals: globs, }) hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct diff --git a/internal/handlers/utility.go b/internal/handlers/utility.go index 7d0fc3d..d24fc40 100644 --- a/internal/handlers/utility.go +++ b/internal/handlers/utility.go @@ -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) +} diff --git a/internal/handlers/utility_test.go b/internal/handlers/utility_test.go index 2367882..a16aaaa 100644 --- a/internal/handlers/utility_test.go +++ b/internal/handlers/utility_test.go @@ -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. diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index f06592a..f84837c 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -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, ) } diff --git a/internal/ircserver/conn.go b/internal/ircserver/conn.go index 78b281a..e448afb 100644 --- a/internal/ircserver/conn.go +++ b/internal/ircserver/conn.go @@ -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 { diff --git a/internal/ircserver/export_test.go b/internal/ircserver/export_test.go index 84e870b..bc7e6da 100644 --- a/internal/ircserver/export_test.go +++ b/internal/ircserver/export_test.go @@ -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 diff --git a/internal/ircserver/integration_test.go b/internal/ircserver/integration_test.go index 456989f..9cef876 100644 --- a/internal/ircserver/integration_test.go +++ b/internal/ircserver/integration_test.go @@ -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") diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index 2a187fb..9797656 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -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) { diff --git a/internal/server/server.go b/internal/server/server.go index 3c7014e..f574eab 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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, diff --git a/internal/service/service.go b/internal/service/service.go index de26722..c147651 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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 diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 2f58e8a..ec044cb 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -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) }