From 17479c4f446be3f92943620e00883ec31b3d344c Mon Sep 17 00:00:00 2001 From: clawbot Date: Wed, 1 Apr 2026 14:16:09 -0700 Subject: [PATCH] fix: rebase onto main, add IRC wire handlers and integration tests for Tier 3 commands Rebase onto main to resolve conflicts from module path rename (sneak.berlin/go/neoirc) and integration test addition. - Update import paths in utility.go to new module path - Add IRC wire protocol handlers for VERSION, ADMIN, INFO, TIME, KILL, and WALLOPS to ircserver/commands.go - Register all 6 new commands in the IRC command dispatch map - Implement proper user MODE +w/-w support for WALLOPS - Add WALLOPS relay delivery in relay.go - Add integration tests for all 7 Tier 3 commands: USERHOST, VERSION, ADMIN, INFO, TIME, KILL, WALLOPS - Add newTestEnvWithOper helper for oper-dependent tests --- internal/handlers/utility.go | 4 +- internal/ircserver/commands.go | 276 +++++++++++++++++++++++- internal/ircserver/conn.go | 8 +- internal/ircserver/integration_test.go | 282 +++++++++++++++++++++++++ internal/ircserver/relay.go | 14 ++ internal/ircserver/server_test.go | 81 +++++++ 6 files changed, 658 insertions(+), 7 deletions(-) diff --git a/internal/handlers/utility.go b/internal/handlers/utility.go index 8c5d99c..68718a3 100644 --- a/internal/handlers/utility.go +++ b/internal/handlers/utility.go @@ -8,8 +8,8 @@ import ( "strings" "time" - "git.eeqj.de/sneak/neoirc/internal/db" - "git.eeqj.de/sneak/neoirc/pkg/irc" + "sneak.berlin/go/neoirc/internal/db" + "sneak.berlin/go/neoirc/pkg/irc" ) // maxUserhostNicks is the maximum number of nicks allowed diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index a336ec1..01ced20 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -431,7 +431,7 @@ func (c *Conn) handleMode( if strings.HasPrefix(target, "#") { c.handleChannelMode(ctx, msg) } else { - c.handleUserMode(msg) + c.handleUserMode(ctx, msg) } } @@ -694,7 +694,10 @@ func (c *Conn) applyChannelModes( } // handleUserMode handles MODE for users. -func (c *Conn) handleUserMode(msg *Message) { +func (c *Conn) handleUserMode( + ctx context.Context, + msg *Message, +) { target := msg.Params[0] if !strings.EqualFold(target, c.nick) { @@ -706,8 +709,85 @@ func (c *Conn) handleUserMode(msg *Message) { return } - // We don't support user modes beyond the basics. - c.sendNumeric(irc.RplUmodeIs, "+") + // Mode query (no mode string). + if len(msg.Params) < 2 { //nolint:mnd + c.sendNumeric( + irc.RplUmodeIs, + c.buildUmodeString(ctx), + ) + + return + } + + modeStr := msg.Params[1] + + if len(modeStr) < 2 { //nolint:mnd + c.sendNumeric( + irc.ErrUmodeUnknownFlag, + "Unknown MODE flag", + ) + + return + } + + adding := modeStr[0] == '+' + + for _, ch := range modeStr[1:] { + switch ch { + case 'w': + _ = c.database.SetSessionWallops( + ctx, c.sessionID, adding, + ) + case 'o': + if adding { + c.sendNumeric( + irc.ErrUmodeUnknownFlag, + "Unknown MODE flag", + ) + + return + } + + _ = c.database.SetSessionOper( + ctx, c.sessionID, false, + ) + default: + c.sendNumeric( + irc.ErrUmodeUnknownFlag, + "Unknown MODE flag", + ) + + return + } + } + + c.sendNumeric( + irc.RplUmodeIs, + c.buildUmodeString(ctx), + ) +} + +// buildUmodeString returns the current user mode string. +func (c *Conn) buildUmodeString( + ctx context.Context, +) string { + modes := "+" + + isOper, err := c.database.IsSessionOper( + ctx, c.sessionID, + ) + if err == nil && isOper { + modes += "o" + } + + isWallops, err := c.database.IsSessionWallops( + ctx, c.sessionID, + ) + if err == nil && isWallops { + modes += "w" + } + + return modes } // handleNames replies with channel member list. @@ -1299,3 +1379,191 @@ func (c *Conn) handleUserhost( strings.Join(replies, " "), ) } + +// handleVersion replies with the server version string. +func (c *Conn) handleVersion(ctx context.Context) { + _ = ctx + + version := "neoirc-0.1" + + c.sendNumeric( + irc.RplVersion, + version+".", c.cfg.ServerName, + "", + ) +} + +// handleAdmin replies with server admin info. +func (c *Conn) handleAdmin(ctx context.Context) { + _ = ctx + + srvName := c.cfg.ServerName + + c.sendNumeric( + irc.RplAdminMe, + srvName, "Administrative info", + ) + + c.sendNumeric( + irc.RplAdminLoc1, + "neoirc server", + ) + + c.sendNumeric( + irc.RplAdminLoc2, + "IRC over HTTP", + ) + + c.sendNumeric( + irc.RplAdminEmail, + "admin@"+srvName, + ) +} + +// handleInfo replies with server software info. +func (c *Conn) handleInfo(ctx context.Context) { + _ = ctx + + infoLines := []string{ + "neoirc — IRC semantics over HTTP", + "Version: neoirc-0.1", + "Written in Go", + } + + for _, line := range infoLines { + c.sendNumeric(irc.RplInfo, line) + } + + c.sendNumeric( + irc.RplEndOfInfo, + "End of /INFO list", + ) +} + +// handleTime replies with the server's current time. +func (c *Conn) handleTime(ctx context.Context) { + _ = ctx + + srvName := c.cfg.ServerName + + c.sendNumeric( + irc.RplTime, + srvName, time.Now().Format(time.RFC1123), + ) +} + +// handleKillCmd forcibly disconnects a target user (oper +// only). +func (c *Conn) handleKillCmd( + ctx context.Context, + msg *Message, +) { + isOper, err := c.database.IsSessionOper( + ctx, c.sessionID, + ) + if err != nil || !isOper { + c.sendNumeric( + irc.ErrNoPrivileges, + "Permission Denied- "+ + "You're not an IRC operator", + ) + + return + } + + if len(msg.Params) < 1 { + c.sendNumeric( + irc.ErrNeedMoreParams, + "KILL", "Not enough parameters", + ) + + return + } + + targetNick := msg.Params[0] + + reason := "KILLed" + if len(msg.Params) > 1 { + reason = msg.Params[1] + } + + if targetNick == c.nick { + c.sendNumeric( + irc.ErrCantKillServer, + "You cannot KILL yourself", + ) + + return + } + + targetSID, lookupErr := c.database.GetSessionByNick( + ctx, targetNick, + ) + if lookupErr != nil { + c.sendNumeric( + irc.ErrNoSuchNick, + targetNick, "No such nick/channel", + ) + + return + } + + quitReason := "Killed (" + c.nick + " (" + reason + "))" + + c.svc.BroadcastQuit( + ctx, targetSID, targetNick, quitReason, + ) +} + +// handleWallopsCmd broadcasts to all +w users (oper only). +func (c *Conn) handleWallopsCmd( + ctx context.Context, + msg *Message, +) { + isOper, err := c.database.IsSessionOper( + ctx, c.sessionID, + ) + if err != nil || !isOper { + c.sendNumeric( + irc.ErrNoPrivileges, + "Permission Denied- "+ + "You're not an IRC operator", + ) + + return + } + + if len(msg.Params) < 1 { + c.sendNumeric( + irc.ErrNeedMoreParams, + "WALLOPS", "Not enough parameters", + ) + + return + } + + message := msg.Params[0] + + wallopsSIDs, wallErr := c.database. + GetWallopsSessionIDs(ctx) + if wallErr != nil { + c.log.Error( + "get wallops sessions failed", + "error", wallErr, + ) + + return + } + + if len(wallopsSIDs) > 0 { + body, mErr := json.Marshal([]string{message}) + if mErr != nil { + return + } + + _, _, _ = c.svc.FanOut( + ctx, irc.CmdWallops, c.nick, "*", + nil, body, nil, wallopsSIDs, + ) + } +} diff --git a/internal/ircserver/conn.go b/internal/ircserver/conn.go index bd4fbb7..78b281a 100644 --- a/internal/ircserver/conn.go +++ b/internal/ircserver/conn.go @@ -130,7 +130,13 @@ func (c *Conn) buildCommandMap() map[string]cmdHandler { "CAP": func(_ context.Context, msg *Message) { c.handleCAP(msg) }, - "USERHOST": c.handleUserhost, + "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.CmdKill: c.handleKillCmd, + irc.CmdWallops: c.handleWallopsCmd, } } diff --git a/internal/ircserver/integration_test.go b/internal/ircserver/integration_test.go index d6da16b..456989f 100644 --- a/internal/ircserver/integration_test.go +++ b/internal/ircserver/integration_test.go @@ -760,6 +760,288 @@ 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") + + // Query single nick. + alice.send("USERHOST bob") + + aliceReply := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 302 ") + }) + assertContains( + t, aliceReply, " 302 ", + "RPL_USERHOST", + ) + assertContains( + t, aliceReply, "bob", + "USERHOST contains queried nick", + ) + + // Query multiple nicks. + bob.send("USERHOST alice bob") + + bobReply := bob.readUntil(func(l string) bool { + return strings.Contains(l, " 302 ") + }) + assertContains( + t, bobReply, " 302 ", + "RPL_USERHOST multi-nick", + ) + assertContains( + t, bobReply, "alice", + "USERHOST multi contains alice", + ) + assertContains( + t, bobReply, "bob", + "USERHOST multi contains bob", + ) +} + +// 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, "test.irc", + "TIME reply includes server name", + ) +} + +// TestIntegrationKill verifies the KILL command: oper can +// kill a user, non-oper cannot. +func TestIntegrationKill(t *testing.T) { + t.Parallel() + + env := newTestEnvWithOper(t) + + alice := env.dial(t) + alice.register("alice") + + bob := env.dial(t) + bob.register("bob") + + // Both join a channel so KILL's QUIT is visible. + 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") + }) + + // Non-oper KILL should fail. + alice.send("KILL bob :nope") + + aliceKillFail := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 481 ") + }) + assertContains( + t, aliceKillFail, " 481 ", + "ERR_NOPRIVILEGES for non-oper KILL", + ) + + // alice becomes oper. + alice.send("OPER testoper testpass") + alice.readUntil(func(l string) bool { + return strings.Contains(l, " 381 ") + }) + + // Oper KILL should succeed. + alice.send("KILL bob :bad behavior") + + // 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", + ) + + // KILL nonexistent nick. + alice.send("KILL nobody123 :gone") + + aliceNoSuch := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 401 ") + }) + assertContains( + t, aliceNoSuch, " 401 ", + "ERR_NOSUCHNICK for KILL missing target", + ) +} + +// 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 becomes oper. + alice.send("OPER testoper testpass") + alice.readUntil(func(l string) bool { + return strings.Contains(l, " 381 ") + }) + + // 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", + ) +} + // TestIntegrationModeSecret tests +s (secret) channel // mode — verifies that +s can be set and the mode is // reflected in MODE queries. diff --git a/internal/ircserver/relay.go b/internal/ircserver/relay.go index cb7d185..18f7f9e 100644 --- a/internal/ircserver/relay.go +++ b/internal/ircserver/relay.go @@ -120,6 +120,8 @@ func (c *Conn) deliverIRCMessage( c.deliverKickMsg(msg, text) case command == "INVITE": c.deliverInviteMsg(msg, text) + case command == irc.CmdWallops: + c.deliverWallops(msg, text) case command == irc.CmdMode: c.deliverMode(msg, text) case command == irc.CmdPing: @@ -305,6 +307,18 @@ func (c *Conn) deliverInviteMsg( c.sendFromServer("NOTICE", c.nick, text) } +// deliverWallops sends a WALLOPS notification. +func (c *Conn) deliverWallops( + msg *db.IRCMessage, + text string, +) { + prefix := msg.From + "!" + msg.From + "@*" + + c.send(FormatMessage( + prefix, irc.CmdWallops, text, + )) +} + // deliverMode sends a MODE change notification. func (c *Conn) deliverMode( msg *db.IRCMessage, diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index ead0963..2a187fb 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -112,6 +112,87 @@ func newTestEnv(t *testing.T) *testEnv { } } +// newTestEnvWithOper creates a test environment with oper +// credentials configured. +func newTestEnvWithOper(t *testing.T) *testEnv { + t.Helper() + + dsn := fmt.Sprintf( + "file:%s?mode=memory&cache=shared&_journal_mode=WAL", + t.Name(), + ) + + conn, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatalf("open db: %v", err) + } + + conn.SetMaxOpenConns(1) + + _, err = conn.ExecContext( + t.Context(), "PRAGMA foreign_keys = ON", + ) + if err != nil { + t.Fatalf("pragma: %v", err) + } + + database := db.NewTestDatabaseFromConn(conn) + + err = database.RunMigrations(t.Context()) + if err != nil { + t.Fatalf("migrate: %v", err) + } + + brk := broker.New() + + cfg := &config.Config{ //nolint:exhaustruct + ServerName: "test.irc", + MOTD: "Welcome to test IRC", + OperName: "testoper", + OperPassword: "testpass", + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + addr := listener.Addr().String() + + err = listener.Close() + if err != nil { + t.Fatalf("close listener: %v", err) + } + + log := slog.New(slog.NewTextHandler( + os.Stderr, + &slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct + )) + + srv := ircserver.NewTestServer(log, cfg, database, brk) + + err = srv.Start(addr) + if err != nil { + t.Fatalf("start irc server: %v", err) + } + + t.Cleanup(func() { + srv.Stop() + + err := conn.Close() + if err != nil { + t.Logf("close db: %v", err) + } + }) + + return &testEnv{ + database: database, + brk: brk, + cfg: cfg, + srv: srv, + } +} + // dial connects to the test server. func (env *testEnv) dial(t *testing.T) *testClient { t.Helper()