From e28fa005ed925ea375d209ebfd8d6cd0dc12670e Mon Sep 17 00:00:00 2001 From: clawbot Date: Thu, 8 Oct 2026 00:24:15 +0000 Subject: [PATCH] Add the tier 3 utility IRC commands (closes #87) USERHOST, VERSION, ADMIN, INFO, TIME, KILL and WALLOPS on both the HTTP API and the IRC listener, and user mode +w. Model: opus-5-5 --- README.md | 17 +- internal/db/export_test.go | 16 + internal/db/queries.go | 203 +++++ internal/db/queries_test.go | 124 +++ internal/db/schema/001_initial.sql | 1 + internal/handlers/api.go | 82 +- internal/handlers/api_test.go | 1 + internal/handlers/utility.go | 576 +++++++++++++ internal/handlers/utility_test.go | 1071 ++++++++++++++++++++++++ internal/ircserver/commands.go | 233 +++++- internal/ircserver/conn.go | 125 ++- internal/ircserver/conn_test.go | 128 +++ internal/ircserver/export_test.go | 28 +- internal/ircserver/integration_test.go | 407 +++++++++ internal/ircserver/relay.go | 14 + internal/ircserver/server_test.go | 148 +++- internal/service/service.go | 336 +++++++- internal/service/service_test.go | 455 +++++++++- pkg/irc/commands.go | 51 +- 19 files changed, 3931 insertions(+), 85 deletions(-) create mode 100644 internal/handlers/utility.go create mode 100644 internal/handlers/utility_test.go create mode 100644 internal/ircserver/conn_test.go diff --git a/README.md b/README.md index 3612738..3cc13d8 100644 --- a/README.md +++ b/README.md @@ -2389,8 +2389,8 @@ IRC_LISTEN_ADDR= | Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` | | Channels | `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE` | | Messaging | `PRIVMSG`, `NOTICE` | -| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY` | -| Operator | `OPER` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) | +| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY`, `USERHOST`, `VERSION`, `ADMIN`, `INFO`, `TIME` | +| Operator | `OPER`, `KILL`, `WALLOPS` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) | ### Protocol Details @@ -2406,6 +2406,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 @@ -2946,6 +2955,10 @@ guess is borne by the server (bcrypt), not the client. from additional devices via `POST /api/v1/login` - [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for all API authentication +- [x] **Tier 3 utility commands** — `USERHOST` (302), `VERSION` (351), `ADMIN` + (256–259), `INFO` (371/374), `TIME` (391), `KILL` (operator-only forced + disconnect), `WALLOPS` (operator-only broadcast to `+w` users) +- [x] **User mode +w** — receive `WALLOPS`, set with `MODE +w` / `-w` ### Future (1.0+) diff --git a/internal/db/export_test.go b/internal/db/export_test.go index 45c0435..fed642d 100644 --- a/internal/db/export_test.go +++ b/internal/db/export_test.go @@ -58,3 +58,19 @@ func (database *Database) Close() error { return nil } + +// ExecForTest runs a raw statement against the test +// database. Tests use it to install SQLite triggers that +// force a specific write to fail, so that the atomicity of +// multi-statement helpers can be exercised. +func (database *Database) ExecForTest( + ctx context.Context, + query string, +) error { + _, err := database.conn.ExecContext(ctx, query) + if err != nil { + return fmt.Errorf("exec for test: %w", err) + } + + return nil +} diff --git a/internal/db/queries.go b/internal/db/queries.go index d840773..2867709 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" @@ -2423,3 +2424,205 @@ func (database *Database) SetChannelUserLimit( return nil } + +// SetSessionUserModes applies a set of user-mode flag +// changes to a session inside a single transaction, so a +// multi-mode change such as "+w-o" is all-or-nothing. A nil +// pointer means the caller did not mention that mode and +// the stored value must be left untouched. +func (database *Database) SetSessionUserModes( + ctx context.Context, + sessionID int64, + wallops *bool, + oper *bool, +) error { + if wallops == nil && oper == nil { + return nil + } + + transaction, err := database.conn.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + + if wallops != nil { + if _, err := transaction.ExecContext( + ctx, + `UPDATE sessions SET is_wallops = ? WHERE id = ?`, + boolToInt(*wallops), sessionID, + ); err != nil { + _ = transaction.Rollback() + + return fmt.Errorf( + "set session wallops: %w", err, + ) + } + } + + if oper != nil { + if _, err := transaction.ExecContext( + ctx, + `UPDATE sessions SET is_oper = ? WHERE id = ?`, + boolToInt(*oper), sessionID, + ); err != nil { + _ = transaction.Rollback() + + return fmt.Errorf("set session oper: %w", err) + } + } + + if err := transaction.Commit(); err != nil { + _ = transaction.Rollback() + + return fmt.Errorf("commit user modes: %w", err) + } + + return nil +} + +// boolToInt renders a Go bool as the 0/1 integer used for +// boolean columns in the SQLite schema. +func boolToInt(value bool) int { + if value { + return 1 + } + + return 0 +} + +// SetSessionWallops sets the wallops (+w) flag on a +// session. +func (database *Database) SetSessionWallops( + ctx context.Context, + sessionID int64, + enabled bool, +) error { + val := 0 + if enabled { + val = 1 + } + + _, err := database.conn.ExecContext( + ctx, + `UPDATE sessions SET is_wallops = ? WHERE id = ?`, + val, sessionID, + ) + if err != nil { + return fmt.Errorf("set session wallops: %w", err) + } + + return nil +} + +// IsSessionWallops returns whether the session has the +// wallops (+w) usermode set. +func (database *Database) IsSessionWallops( + ctx context.Context, + sessionID int64, +) (bool, error) { + var isWallops int + + err := database.conn.QueryRowContext( + ctx, + `SELECT is_wallops FROM sessions WHERE id = ?`, + sessionID, + ).Scan(&isWallops) + if err != nil { + return false, fmt.Errorf( + "check session wallops: %w", err, + ) + } + + return isWallops != 0, nil +} + +// GetWallopsSessionIDs returns all session IDs that have +// the wallops (+w) usermode set. +func (database *Database) GetWallopsSessionIDs( + ctx context.Context, +) ([]int64, error) { + rows, err := database.conn.QueryContext( + ctx, + `SELECT id FROM sessions WHERE is_wallops = 1`, + ) + if err != nil { + return nil, fmt.Errorf( + "get wallops sessions: %w", err, + ) + } + + defer func() { _ = rows.Close() }() + + var ids []int64 + + for rows.Next() { + var sessionID int64 + if scanErr := rows.Scan(&sessionID); scanErr != nil { + return nil, fmt.Errorf( + "scan wallops session: %w", scanErr, + ) + } + + ids = append(ids, sessionID) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf( + "iterate wallops sessions: %w", err, + ) + } + + return ids, nil +} + +// UserhostInfo holds the data needed for RPL_USERHOST. +type UserhostInfo struct { + Nick string + Username string + Hostname string + IsOper bool + AwayMessage string +} + +// GetUserhostInfo returns USERHOST info for the given +// 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, +) ([]UserhostInfo, error) { + if len(nicks) == 0 { + return nil, nil + } + + results := make([]UserhostInfo, 0, len(nicks)) + + for _, nick := range nicks { + var info UserhostInfo + + err := database.conn.QueryRowContext( + ctx, + `SELECT nick, username, hostname, + is_oper, away_message + FROM sessions WHERE nick = ?`, + nick, + ).Scan( + &info.Nick, &info.Username, &info.Hostname, + &info.IsOper, &info.AwayMessage, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + continue // nick not online + } + + return nil, fmt.Errorf( + "userhost lookup %q: %w", nick, err, + ) + } + + results = append(results, info) + } + + return results, nil +} diff --git a/internal/db/queries_test.go b/internal/db/queries_test.go index 9c201d2..7898cf9 100644 --- a/internal/db/queries_test.go +++ b/internal/db/queries_test.go @@ -1491,3 +1491,127 @@ func TestChannelUserLimit(t *testing.T) { t.Fatalf("expected 0, got %d", limit) } } + +// TestSetSessionUserModesIsAtomic proves that a multi-mode +// change is all-or-nothing. A trigger makes the is_oper +// UPDATE fail after the is_wallops UPDATE has already run, +// which is exactly the "+w-o" partial-failure the previous +// implementation exhibited: it issued the two UPDATEs +// independently, so +w persisted while the caller reported +// total failure. +func TestSetSessionUserModesIsAtomic(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + sessionID, _, _, err := database.CreateSession( + ctx, "alice", "", "", "", + ) + if err != nil { + t.Fatal(err) + } + + err = database.ExecForTest(ctx, + `CREATE TRIGGER reject_oper + BEFORE UPDATE OF is_oper ON sessions + BEGIN SELECT RAISE(ABORT, 'oper write rejected'); + END`, + ) + if err != nil { + t.Fatal(err) + } + + wallops := true + oper := false + + err = database.SetSessionUserModes( + ctx, sessionID, &wallops, &oper, + ) + if err == nil { + t.Fatal("expected the rejected oper write to fail") + } + + gotWallops, err := database.IsSessionWallops( + ctx, sessionID, + ) + if err != nil { + t.Fatal(err) + } + + if gotWallops { + t.Error( + "wallops persisted despite the transaction " + + "failing; the apply stage is not atomic", + ) + } +} + +// TestSetSessionUserModesAppliesBoth is the success-path +// counterpart: when nothing fails, both flags are written, +// and a nil pointer leaves that flag untouched. +func TestSetSessionUserModesAppliesBoth(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + sessionID, _, _, err := database.CreateSession( + ctx, "alice", "", "", "", + ) + if err != nil { + t.Fatal(err) + } + + if err := database.SetSessionOper( + ctx, sessionID, true, + ); err != nil { + t.Fatal(err) + } + + wallops := true + oper := false + + if err := database.SetSessionUserModes( + ctx, sessionID, &wallops, &oper, + ); err != nil { + t.Fatal(err) + } + + gotWallops, err := database.IsSessionWallops( + ctx, sessionID, + ) + if err != nil { + t.Fatal(err) + } + + gotOper, err := database.IsSessionOper(ctx, sessionID) + if err != nil { + t.Fatal(err) + } + + if !gotWallops || gotOper { + t.Errorf( + "want wallops=true oper=false, got %v/%v", + gotWallops, gotOper, + ) + } + + // A nil pointer must leave the stored value alone. + if err := database.SetSessionUserModes( + ctx, sessionID, nil, nil, + ); err != nil { + t.Fatal(err) + } + + gotWallops, err = database.IsSessionWallops( + ctx, sessionID, + ) + if err != nil { + t.Fatal(err) + } + + if !gotWallops { + t.Error("nil pointers must not clear wallops") + } +} diff --git a/internal/db/schema/001_initial.sql b/internal/db/schema/001_initial.sql index e53c48b..1a1b27f 100644 --- a/internal/db/schema/001_initial.sql +++ b/internal/db/schema/001_initial.sql @@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS sessions ( hostname TEXT NOT NULL DEFAULT '', ip TEXT NOT NULL DEFAULT '', is_oper INTEGER NOT NULL DEFAULT 0, + is_wallops INTEGER NOT NULL DEFAULT 0, password_hash TEXT NOT NULL DEFAULT '', signing_key TEXT NOT NULL DEFAULT '', away_message TEXT NOT NULL DEFAULT '', diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 3da43f3..d2fc0e7 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -962,10 +962,12 @@ func (hdlr *Handlers) dispatchCommand( bodyLines func() []string, ) { switch command { - case irc.CmdAway: - hdlr.handleAway( + case irc.CmdAway, irc.CmdNick, + irc.CmdPass, irc.CmdInvite: + hdlr.dispatchBodyOnlyCommand( writer, request, - sessionID, clientID, nick, bodyLines, + sessionID, clientID, nick, + command, bodyLines, ) case irc.CmdPrivmsg, irc.CmdNotice: hdlr.handlePrivmsg( @@ -984,27 +986,12 @@ func (hdlr *Handlers) dispatchCommand( writer, request, sessionID, clientID, nick, target, body, ) - case irc.CmdNick: - hdlr.handleNick( - writer, request, - sessionID, clientID, nick, bodyLines, - ) - case irc.CmdPass: - hdlr.handlePass( - writer, request, - sessionID, clientID, nick, bodyLines, - ) case irc.CmdTopic: hdlr.handleTopic( writer, request, sessionID, clientID, nick, target, body, bodyLines, ) - case irc.CmdInvite: - hdlr.handleInvite( - writer, request, - sessionID, clientID, nick, bodyLines, - ) case irc.CmdKick: hdlr.handleKick( writer, request, @@ -1015,12 +1002,15 @@ func (hdlr *Handlers) dispatchCommand( hdlr.handleQuit( writer, request, sessionID, nick, body, ) - case irc.CmdOper: - hdlr.handleOper( + case irc.CmdOper, irc.CmdKill, irc.CmdWallops: + hdlr.dispatchOperCommand( writer, request, - sessionID, clientID, nick, bodyLines, + sessionID, clientID, nick, + command, bodyLines, ) - case irc.CmdMotd, irc.CmdPing: + case irc.CmdMotd, irc.CmdPing, + irc.CmdVersion, irc.CmdAdmin, + irc.CmdInfo, irc.CmdTime: hdlr.dispatchInfoCommand( writer, request, sessionID, clientID, nick, @@ -1075,6 +1065,11 @@ func (hdlr *Handlers) dispatchQueryCommand( writer, request, sessionID, clientID, nick, ) + case irc.CmdUserhost: + hdlr.handleUserhost( + writer, request, + sessionID, clientID, nick, bodyLines, + ) default: hdlr.enqueueNumeric( request.Context(), clientID, @@ -1868,7 +1863,8 @@ func (hdlr *Handlers) deliverSetTopicNumerics( } // dispatchInfoCommand handles informational IRC commands -// that produce server-side numerics (MOTD, PING). +// that produce server-side numerics (MOTD, PING, +// VERSION, ADMIN, INFO, TIME). func (hdlr *Handlers) dispatchInfoCommand( writer http.ResponseWriter, request *http.Request, @@ -1894,6 +1890,34 @@ func (hdlr *Handlers) dispatchInfoCommand( }, http.StatusOK) + return + case irc.CmdVersion: + hdlr.handleVersion( + writer, request, + sessionID, clientID, nick, + ) + + return + case irc.CmdAdmin: + hdlr.handleAdmin( + writer, request, + sessionID, clientID, nick, + ) + + return + case irc.CmdInfo: + hdlr.handleInfo( + writer, request, + sessionID, clientID, nick, + ) + + return + case irc.CmdTime: + hdlr.handleTime( + writer, request, + sessionID, clientID, nick, + ) + return } @@ -1951,15 +1975,11 @@ func (hdlr *Handlers) handleMode( channel := target if !strings.HasPrefix(channel, "#") { - // User mode query — return empty modes. - hdlr.enqueueNumeric( - request.Context(), clientID, - irc.RplUmodeIs, nick, nil, "+", + hdlr.handleUserMode( + writer, request, + sessionID, clientID, nick, target, + bodyLines, ) - hdlr.broker.Notify(sessionID) - hdlr.respondJSON(writer, request, - map[string]string{statusKey: "ok"}, - http.StatusOK) return } diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 594ef40..4fff74e 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -293,6 +293,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 new file mode 100644 index 0000000..d24fc40 --- /dev/null +++ b/internal/handlers/utility.go @@ -0,0 +1,576 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "sneak.berlin/go/neoirc/internal/db" + "sneak.berlin/go/neoirc/internal/service" + "sneak.berlin/go/neoirc/pkg/irc" +) + +// maxUserhostNicks is the maximum number of nicks allowed +// in a single USERHOST query (RFC 2812). +const maxUserhostNicks = 5 + +// dispatchBodyOnlyCommand routes commands that take +// (writer, request, sessionID, clientID, nick, bodyLines). +func (hdlr *Handlers) dispatchBodyOnlyCommand( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, command string, + bodyLines func() []string, +) { + switch command { + case irc.CmdAway: + hdlr.handleAway( + writer, request, + sessionID, clientID, nick, bodyLines, + ) + case irc.CmdNick: + hdlr.handleNick( + writer, request, + sessionID, clientID, nick, bodyLines, + ) + case irc.CmdPass: + hdlr.handlePass( + writer, request, + sessionID, clientID, nick, bodyLines, + ) + case irc.CmdInvite: + hdlr.handleInvite( + writer, request, + sessionID, clientID, nick, bodyLines, + ) + } +} + +// dispatchOperCommand routes oper-related commands (OPER, +// KILL, WALLOPS) to their handlers. +func (hdlr *Handlers) dispatchOperCommand( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, command string, + bodyLines func() []string, +) { + switch command { + case irc.CmdOper: + hdlr.handleOper( + writer, request, + sessionID, clientID, nick, bodyLines, + ) + case irc.CmdKill: + hdlr.handleKill( + writer, request, + sessionID, clientID, nick, bodyLines, + ) + case irc.CmdWallops: + hdlr.handleWallops( + writer, request, + sessionID, clientID, nick, bodyLines, + ) + } +} + +// handleUserhost handles the USERHOST command. +// Returns user@host info for up to 5 nicks. +func (hdlr *Handlers) handleUserhost( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, + bodyLines func() []string, +) { + ctx := request.Context() + + lines := bodyLines() + if len(lines) == 0 { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNeedMoreParams, nick, + []string{irc.CmdUserhost}, + "Not enough parameters", + ) + + return + } + + // Limit to 5 nicks per RFC 2812. + nicks := lines + if len(nicks) > maxUserhostNicks { + nicks = nicks[:maxUserhostNicks] + } + + infos, err := hdlr.params.Database.GetUserhostInfo( + ctx, nicks, + ) + if err != nil { + hdlr.log.Error( + "userhost query failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return + } + + replyStr := hdlr.buildUserhostReply(infos) + + hdlr.enqueueNumeric( + ctx, clientID, irc.RplUserHost, nick, nil, + replyStr, + ) + + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// buildUserhostReply builds the RPL_USERHOST reply +// string per RFC 2812. +func (hdlr *Handlers) buildUserhostReply( + infos []db.UserhostInfo, +) string { + replies := make([]string, 0, len(infos)) + + for idx := range infos { + info := &infos[idx] + + username := info.Username + if username == "" { + username = info.Nick + } + + hostname := info.Hostname + if hostname == "" { + hostname = hdlr.serverName() + } + + operStar := "" + if info.IsOper { + operStar = "*" + } + + awayPrefix := "+" + if info.AwayMessage != "" { + awayPrefix = "-" + } + + replies = append(replies, + info.Nick+operStar+"="+ + awayPrefix+username+"@"+hostname, + ) + } + + return strings.Join(replies, " ") +} + +// handleVersion handles the VERSION command. +// Returns the server version string. +func (hdlr *Handlers) handleVersion( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, +) { + ctx := request.Context() + srvName := hdlr.serverName() + version := hdlr.svc.ServerVersion() + + // 351 RPL_VERSION + hdlr.enqueueNumeric( + ctx, clientID, irc.RplVersion, nick, + []string{version + ".", srvName}, + "", + ) + + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// handleAdmin handles the ADMIN command. +// Returns server admin contact info. +func (hdlr *Handlers) handleAdmin( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, +) { + ctx := request.Context() + srvName := hdlr.serverName() + + // 256 RPL_ADMINME + hdlr.enqueueNumeric( + ctx, clientID, irc.RplAdminMe, nick, + []string{srvName}, + "Administrative info", + ) + + // 257 RPL_ADMINLOC1 + hdlr.enqueueNumeric( + ctx, clientID, irc.RplAdminLoc1, nick, nil, + "neoirc server", + ) + + // 258 RPL_ADMINLOC2 + hdlr.enqueueNumeric( + ctx, clientID, irc.RplAdminLoc2, nick, nil, + "IRC over HTTP", + ) + + // 259 RPL_ADMINEMAIL + hdlr.enqueueNumeric( + ctx, clientID, irc.RplAdminEmail, nick, nil, + "admin@"+srvName, + ) + + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// handleInfo handles the INFO command. +// Returns server software information. +func (hdlr *Handlers) handleInfo( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, +) { + ctx := request.Context() + + for _, line := range hdlr.svc.InfoLines() { + // 371 RPL_INFO + hdlr.enqueueNumeric( + ctx, clientID, irc.RplInfo, nick, nil, + line, + ) + } + + // 374 RPL_ENDOFINFO + hdlr.enqueueNumeric( + ctx, clientID, irc.RplEndOfInfo, nick, nil, + "End of /INFO list", + ) + + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// handleTime handles the TIME command. +// Returns the server's local time in RFC format. +func (hdlr *Handlers) handleTime( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, +) { + ctx := request.Context() + srvName := hdlr.serverName() + + // 391 RPL_TIME + hdlr.enqueueNumeric( + ctx, clientID, irc.RplTime, nick, + []string{srvName}, + time.Now().Format(time.RFC1123), + ) + + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// handleKill handles the KILL command. +// Forcibly disconnects a user (oper only). +func (hdlr *Handlers) handleKill( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, + bodyLines func() []string, +) { + ctx := request.Context() + + // Check oper status. + isOper, err := hdlr.params.Database.IsSessionOper( + ctx, sessionID, + ) + if err != nil || !isOper { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNoPrivileges, nick, nil, + "Permission Denied- You're not an IRC operator", + ) + + return + } + + lines := bodyLines() + + var targetNick string + if len(lines) > 0 { + targetNick = strings.TrimSpace(lines[0]) + } + + if targetNick == "" { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNeedMoreParams, nick, + []string{irc.CmdKill}, + "Not enough parameters", + ) + + return + } + + reason := "KILLed" + if len(lines) > 1 { + reason = lines[1] + } + + targetSID, lookupErr := hdlr.params.Database. + GetSessionByNick(ctx, targetNick) + if lookupErr != nil { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNoSuchNick, nick, + []string{targetNick}, + "No such nick/channel", + ) + + return + } + + // Do not allow killing yourself. + if targetSID == sessionID { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrCantKillServer, nick, nil, + "You cannot KILL yourself", + ) + + return + } + + quitReason := "Killed (" + nick + " (" + reason + "))" + + // 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, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// handleWallops handles the WALLOPS command. +// Broadcasts a message to all users with +w usermode +// (oper only). +func (hdlr *Handlers) handleWallops( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, + bodyLines func() []string, +) { + ctx := request.Context() + + // Check oper status. + isOper, err := hdlr.params.Database.IsSessionOper( + ctx, sessionID, + ) + if err != nil || !isOper { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNoPrivileges, nick, nil, + "Permission Denied- You're not an IRC operator", + ) + + return + } + + lines := bodyLines() + if len(lines) == 0 { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNeedMoreParams, nick, + []string{irc.CmdWallops}, + "Not enough parameters", + ) + + return + } + + message := strings.Join(lines, " ") + + wallopsSIDs, err := hdlr.params.Database. + GetWallopsSessionIDs(ctx) + if err != nil { + hdlr.log.Error( + "get wallops sessions failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return + } + + if len(wallopsSIDs) > 0 { + body, mErr := json.Marshal([]string{message}) + if mErr != nil { + hdlr.log.Error( + "marshal wallops body", "error", mErr, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return + } + + _ = hdlr.fanOutSilent( + request, irc.CmdWallops, nick, "*", + json.RawMessage(body), wallopsSIDs, + ) + } + + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// handleUserMode handles user mode queries and changes +// (e.g., MODE nick, MODE nick +w). Delegates to the +// shared service.ApplyUserMode / service.QueryUserMode so +// that mode string processing is identical for both the +// HTTP API and IRC wire protocol. +func (hdlr *Handlers) handleUserMode( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, target string, + bodyLines func() []string, +) { + 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 { + hdlr.changeUserMode( + writer, request, + sessionID, clientID, nick, lines[0], + ) + + return + } + + // Mode query — delegate to shared service. + 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, + modeStr, + ) + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + 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 new file mode 100644 index 0000000..a16aaaa --- /dev/null +++ b/internal/handlers/utility_test.go @@ -0,0 +1,1071 @@ +// Tests for Tier 3 utility IRC commands: USERHOST, +// VERSION, ADMIN, INFO, TIME, KILL, WALLOPS. +// +//nolint:paralleltest +package handlers_test + +import ( + "strings" + "testing" +) + +// --- USERHOST --- + +func TestUserhostSingleNick(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("alice") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "USERHOST", + bodyKey: []string{"alice"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 302 RPL_USERHOST. + msg := findNumericWithParams(msgs, "302") + if msg == nil { + t.Fatalf( + "expected RPL_USERHOST (302), got %v", + msgs, + ) + } + + // Body should contain "alice" with the + // nick=+user@host format. + body := getNumericBody(msg) + if !strings.Contains(body, "alice") { + t.Fatalf( + "expected body to contain 'alice', got %q", + body, + ) + } + + // '+' means not away. + if !strings.Contains(body, "=+") { + t.Fatalf( + "expected not-away prefix '=+', got %q", + body, + ) + } +} + +func TestUserhostMultipleNicks(t *testing.T) { + tserver := newTestServer(t) + + token1 := tserver.createSession("bob") + token2 := tserver.createSession("carol") + + _ = token2 + + _, lastID := tserver.pollMessages(token1, 0) + + tserver.sendCommand(token1, map[string]any{ + commandKey: "USERHOST", + bodyKey: []string{"bob", "carol"}, + }) + + msgs, _ := tserver.pollMessages(token1, lastID) + + msg := findNumericWithParams(msgs, "302") + if msg == nil { + t.Fatalf( + "expected RPL_USERHOST (302), got %v", + msgs, + ) + } + + body := getNumericBody(msg) + if !strings.Contains(body, "bob") { + t.Fatalf( + "expected body to contain 'bob', got %q", + body, + ) + } + + if !strings.Contains(body, "carol") { + t.Fatalf( + "expected body to contain 'carol', got %q", + body, + ) + } +} + +func TestUserhostNonexistentNick(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("dave") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "USERHOST", + bodyKey: []string{"nobody"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Should still get 302 but with empty body. + msg := findNumericWithParams(msgs, "302") + if msg == nil { + t.Fatalf( + "expected RPL_USERHOST (302), got %v", + msgs, + ) + } +} + +func TestUserhostNoParams(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("eve") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "USERHOST", + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 461 ERR_NEEDMOREPARAMS. + if !findNumeric(msgs, "461") { + t.Fatalf( + "expected ERR_NEEDMOREPARAMS (461), got %v", + msgs, + ) + } +} + +func TestUserhostShowsOper(t *testing.T) { + tserver := newTestServerWithOper(t) + + token := tserver.createSession("opernick") + _, lastID := tserver.pollMessages(token, 0) + + // Authenticate as oper. + tserver.sendCommand(token, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, testOperPassword}, + }) + + _, lastID = tserver.pollMessages(token, lastID) + + // USERHOST should show '*' for oper. + tserver.sendCommand(token, map[string]any{ + commandKey: "USERHOST", + bodyKey: []string{"opernick"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + msg := findNumericWithParams(msgs, "302") + if msg == nil { + t.Fatalf( + "expected RPL_USERHOST (302), got %v", + msgs, + ) + } + + body := getNumericBody(msg) + if !strings.Contains(body, "opernick*=") { + t.Fatalf( + "expected oper '*' in reply, got %q", + body, + ) + } +} + +func TestUserhostShowsAway(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("awaynick") + _, lastID := tserver.pollMessages(token, 0) + + // Set away. + tserver.sendCommand(token, map[string]any{ + commandKey: "AWAY", + bodyKey: []string{"gone fishing"}, + }) + + _, lastID = tserver.pollMessages(token, lastID) + + // USERHOST should show '-' for away. + tserver.sendCommand(token, map[string]any{ + commandKey: "USERHOST", + bodyKey: []string{"awaynick"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + msg := findNumericWithParams(msgs, "302") + if msg == nil { + t.Fatalf( + "expected RPL_USERHOST (302), got %v", + msgs, + ) + } + + body := getNumericBody(msg) + if !strings.Contains(body, "=-") { + t.Fatalf( + "expected away prefix '=-' in reply, got %q", + body, + ) + } +} + +// --- VERSION --- + +func TestVersion(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("frank") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "VERSION", + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 351 RPL_VERSION. + msg := findNumericWithParams(msgs, "351") + if msg == nil { + t.Fatalf( + "expected RPL_VERSION (351), got %v", + msgs, + ) + } + + params := getNumericParams(msg) + if len(params) == 0 { + t.Fatal("expected VERSION params, got none") + } + + // First param should contain version string. + if !strings.Contains(params[0], "test") { + t.Fatalf( + "expected version to contain 'test', got %q", + params[0], + ) + } +} + +// --- ADMIN --- + +func TestAdmin(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("grace") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "ADMIN", + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 256 RPL_ADMINME. + if !findNumeric(msgs, "256") { + t.Fatalf( + "expected RPL_ADMINME (256), got %v", + msgs, + ) + } + + // Expect 257 RPL_ADMINLOC1. + if !findNumeric(msgs, "257") { + t.Fatalf( + "expected RPL_ADMINLOC1 (257), got %v", + msgs, + ) + } + + // Expect 258 RPL_ADMINLOC2. + if !findNumeric(msgs, "258") { + t.Fatalf( + "expected RPL_ADMINLOC2 (258), got %v", + msgs, + ) + } + + // Expect 259 RPL_ADMINEMAIL. + if !findNumeric(msgs, "259") { + t.Fatalf( + "expected RPL_ADMINEMAIL (259), got %v", + msgs, + ) + } +} + +// --- INFO --- + +func TestInfo(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("hank") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "INFO", + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 371 RPL_INFO (at least one). + if !findNumeric(msgs, "371") { + t.Fatalf( + "expected RPL_INFO (371), got %v", + msgs, + ) + } + + // Expect 374 RPL_ENDOFINFO. + if !findNumeric(msgs, "374") { + t.Fatalf( + "expected RPL_ENDOFINFO (374), got %v", + msgs, + ) + } +} + +// --- TIME --- + +func TestTime(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("iris") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "TIME", + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 391 RPL_TIME. + msg := findNumericWithParams(msgs, "391") + if msg == nil { + t.Fatalf( + "expected RPL_TIME (391), got %v", + msgs, + ) + } +} + +// --- KILL --- + +func TestKillSuccess(t *testing.T) { + tserver := newTestServerWithOper(t) + + // Create the victim first. + victimToken := tserver.createSession("victim") + _ = victimToken + + // Create oper user. + operToken := tserver.createSession("killer") + _, lastID := tserver.pollMessages(operToken, 0) + + // Authenticate as oper. + tserver.sendCommand(operToken, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, testOperPassword}, + }) + + _, lastID = tserver.pollMessages(operToken, lastID) + + // Kill the victim. + status, result := tserver.sendCommand( + operToken, map[string]any{ + commandKey: "KILL", + bodyKey: []string{"victim", "go away"}, + }, + ) + + if status != 200 { + t.Fatalf("expected 200, got %d: %v", status, result) + } + + resultStatus, _ := result[statusKey].(string) + if resultStatus != "ok" { + t.Fatalf( + "expected status ok, got %v", + result, + ) + } + + // Verify the victim's session is gone by trying + // to WHOIS them. + tserver.sendCommand(operToken, map[string]any{ + commandKey: "WHOIS", + toKey: "victim", + }) + + msgs, _ := tserver.pollMessages(operToken, lastID) + + // Should get 401 ERR_NOSUCHNICK. + if !findNumeric(msgs, "401") { + t.Fatalf( + "expected victim to be gone (401), got %v", + msgs, + ) + } +} + +func TestKillNotOper(t *testing.T) { + tserver := newTestServer(t) + + _ = tserver.createSession("target") + + token := tserver.createSession("notoper") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "KILL", + bodyKey: []string{"target", "no reason"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 481 ERR_NOPRIVILEGES. + if !findNumeric(msgs, "481") { + t.Fatalf( + "expected ERR_NOPRIVILEGES (481), got %v", + msgs, + ) + } +} + +func TestKillNoParams(t *testing.T) { + tserver := newTestServerWithOper(t) + + token := tserver.createSession("opertest") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, testOperPassword}, + }) + + _, lastID = tserver.pollMessages(token, lastID) + + tserver.sendCommand(token, map[string]any{ + commandKey: "KILL", + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 461 ERR_NEEDMOREPARAMS. + if !findNumeric(msgs, "461") { + t.Fatalf( + "expected ERR_NEEDMOREPARAMS (461), got %v", + msgs, + ) + } +} + +// sendOperKillCommand is a helper that creates an oper +// session, authenticates, then sends KILL with the given +// target nick, and returns the resulting messages. +func sendOperKillCommand( + t *testing.T, + tserver *testServer, + operNick, targetNick string, +) []map[string]any { + t.Helper() + + token := tserver.createSession(operNick) + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, testOperPassword}, + }) + + _, lastID = tserver.pollMessages(token, lastID) + + tserver.sendCommand(token, map[string]any{ + commandKey: "KILL", + bodyKey: []string{targetNick}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + return msgs +} + +func TestKillNonexistentUser(t *testing.T) { + tserver := newTestServerWithOper(t) + + msgs := sendOperKillCommand( + t, tserver, "opertest2", "ghost", + ) + + // Expect 401 ERR_NOSUCHNICK. + if !findNumeric(msgs, "401") { + t.Fatalf( + "expected ERR_NOSUCHNICK (401), got %v", + msgs, + ) + } +} + +func TestKillSelf(t *testing.T) { + tserver := newTestServerWithOper(t) + + msgs := sendOperKillCommand( + t, tserver, "selfkiller", "selfkiller", + ) + + // Expect 483 ERR_CANTKILLSERVER. + if !findNumeric(msgs, "483") { + t.Fatalf( + "expected ERR_CANTKILLSERVER (483), got %v", + msgs, + ) + } +} + +func TestKillBroadcastsQuit(t *testing.T) { + tserver := newTestServerWithOper(t) + + // Create victim and join a channel. + victimToken := tserver.createSession("vuser") + + tserver.sendCommand(victimToken, map[string]any{ + commandKey: joinCmd, + toKey: "#killtest", + }) + + // Create observer and join same channel. + observerToken := tserver.createSession("observer") + + tserver.sendCommand(observerToken, map[string]any{ + commandKey: joinCmd, + toKey: "#killtest", + }) + + _, lastObs := tserver.pollMessages(observerToken, 0) + + // Create oper. + operToken := tserver.createSession("theoper2") + + tserver.pollMessages(operToken, 0) + + tserver.sendCommand(operToken, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, testOperPassword}, + }) + + tserver.pollMessages(operToken, 0) + + // Kill the victim. + tserver.sendCommand(operToken, map[string]any{ + commandKey: "KILL", + bodyKey: []string{"vuser", "testing kill"}, + }) + + // Observer should see a QUIT message. + msgs, _ := tserver.pollMessages(observerToken, lastObs) + + foundQuit := false + + for _, msg := range msgs { + cmd, _ := msg["command"].(string) + if cmd == "QUIT" { + from, _ := msg["from"].(string) + if from == "vuser" { + foundQuit = true + + break + } + } + } + + if !foundQuit { + t.Fatalf( + "expected QUIT from vuser, got %v", + msgs, + ) + } +} + +// --- WALLOPS --- + +func TestWallopsSuccess(t *testing.T) { + tserver := newTestServerWithOper(t) + + // Create receiver with +w. + receiverToken := tserver.createSession("receiver") + + tserver.sendCommand(receiverToken, map[string]any{ + commandKey: "MODE", + toKey: "receiver", + bodyKey: []string{"+w"}, + }) + + _, lastRecv := tserver.pollMessages(receiverToken, 0) + + // Create oper. + operToken := tserver.createSession("walloper") + + tserver.pollMessages(operToken, 0) + + tserver.sendCommand(operToken, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, testOperPassword}, + }) + + tserver.pollMessages(operToken, 0) + + // Also set +w on oper so they receive it too. + tserver.sendCommand(operToken, map[string]any{ + commandKey: "MODE", + toKey: "walloper", + bodyKey: []string{"+w"}, + }) + + tserver.pollMessages(operToken, 0) + + // Send WALLOPS. + tserver.sendCommand(operToken, map[string]any{ + commandKey: "WALLOPS", + bodyKey: []string{"server going down"}, + }) + + // Receiver should get the WALLOPS message. + msgs, _ := tserver.pollMessages(receiverToken, lastRecv) + + foundWallops := false + + for _, msg := range msgs { + cmd, _ := msg["command"].(string) + if cmd == "WALLOPS" { + foundWallops = true + + break + } + } + + if !foundWallops { + t.Fatalf( + "expected WALLOPS message, got %v", + msgs, + ) + } +} + +func TestWallopsNotOper(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("notoper2") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "WALLOPS", + bodyKey: []string{"hello"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 481 ERR_NOPRIVILEGES. + if !findNumeric(msgs, "481") { + t.Fatalf( + "expected ERR_NOPRIVILEGES (481), got %v", + msgs, + ) + } +} + +func TestWallopsNoParams(t *testing.T) { + tserver := newTestServerWithOper(t) + + token := tserver.createSession("operempty") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, testOperPassword}, + }) + + _, lastID = tserver.pollMessages(token, lastID) + + tserver.sendCommand(token, map[string]any{ + commandKey: "WALLOPS", + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 461 ERR_NEEDMOREPARAMS. + if !findNumeric(msgs, "461") { + t.Fatalf( + "expected ERR_NEEDMOREPARAMS (461), got %v", + msgs, + ) + } +} + +func TestWallopsNotReceivedWithoutW(t *testing.T) { + tserver := newTestServerWithOper(t) + + // Create receiver WITHOUT +w. + receiverToken := tserver.createSession("nowallops") + _, lastRecv := tserver.pollMessages(receiverToken, 0) + + // Create oper. + operToken := tserver.createSession("walloper2") + + tserver.pollMessages(operToken, 0) + + tserver.sendCommand(operToken, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, testOperPassword}, + }) + + tserver.pollMessages(operToken, 0) + + // Send WALLOPS. + tserver.sendCommand(operToken, map[string]any{ + commandKey: "WALLOPS", + bodyKey: []string{"secret message"}, + }) + + // Receiver should NOT get the WALLOPS message. + msgs, _ := tserver.pollMessages(receiverToken, lastRecv) + + for _, msg := range msgs { + cmd, _ := msg["command"].(string) + if cmd == "WALLOPS" { + t.Fatalf( + "did not expect WALLOPS for user "+ + "without +w, got %v", + msgs, + ) + } + } +} + +// --- User Mode +w --- + +func TestUserModeSetW(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("wmoder") + _, lastID := tserver.pollMessages(token, 0) + + // Set +w. + tserver.sendCommand(token, map[string]any{ + commandKey: "MODE", + toKey: "wmoder", + bodyKey: []string{"+w"}, + }) + + msgs, lastID := tserver.pollMessages(token, lastID) + + // Expect 221 RPL_UMODEIS with "+w". + 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 mode string to contain 'w', got %q", + body, + ) + } + + // Now query mode. + tserver.sendCommand(token, map[string]any{ + commandKey: "MODE", + toKey: "wmoder", + }) + + msgs, _ = tserver.pollMessages(token, lastID) + + msg = findNumericWithParams(msgs, "221") + if msg == nil { + t.Fatalf( + "expected RPL_UMODEIS (221) on query, got %v", + msgs, + ) + } + + body = getNumericBody(msg) + if !strings.Contains(body, "w") { + t.Fatalf( + "expected mode '+w' in query, got %q", + body, + ) + } +} + +func TestUserModeUnsetW(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("wunsetter") + _, lastID := tserver.pollMessages(token, 0) + + // Set +w first. + tserver.sendCommand(token, map[string]any{ + commandKey: "MODE", + toKey: "wunsetter", + bodyKey: []string{"+w"}, + }) + + _, lastID = tserver.pollMessages(token, lastID) + + // Unset -w. + tserver.sendCommand(token, map[string]any{ + commandKey: "MODE", + toKey: "wunsetter", + bodyKey: []string{"-w"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + 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 removed, got %q", + body, + ) + } +} + +func TestUserModeUnknownFlag(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("badmode") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "MODE", + toKey: "badmode", + bodyKey: []string{"+z"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 501 ERR_UMODEUNKNOWNFLAG. + if !findNumeric(msgs, "501") { + t.Fatalf( + "expected ERR_UMODEUNKNOWNFLAG (501), got %v", + msgs, + ) + } +} + +func TestUserModeCannotSetO(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("tryoper") + _, lastID := tserver.pollMessages(token, 0) + + // Try to set +o via MODE (should fail). + tserver.sendCommand(token, map[string]any{ + commandKey: "MODE", + toKey: "tryoper", + bodyKey: []string{"+o"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 501 ERR_UMODEUNKNOWNFLAG. + if !findNumeric(msgs, "501") { + t.Fatalf( + "expected ERR_UMODEUNKNOWNFLAG (501), got %v", + msgs, + ) + } +} + +func TestUserModeDeoper(t *testing.T) { + tserver := newTestServerWithOper(t) + + token := tserver.createSession("deoper") + _, lastID := tserver.pollMessages(token, 0) + + // Authenticate as oper. + tserver.sendCommand(token, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, testOperPassword}, + }) + + _, lastID = tserver.pollMessages(token, lastID) + + // Use MODE -o to de-oper. + tserver.sendCommand(token, map[string]any{ + commandKey: "MODE", + toKey: "deoper", + bodyKey: []string{"-o"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + msg := findNumericWithParams(msgs, "221") + if msg == nil { + t.Fatalf( + "expected RPL_UMODEIS (221), got %v", + msgs, + ) + } + + body := getNumericBody(msg) + if strings.Contains(body, "o") { + t.Fatalf( + "expected 'o' to be removed, got %q", + body, + ) + } +} + +func TestUserModeCannotChangeOtherUser(t *testing.T) { + tserver := newTestServer(t) + + _ = tserver.createSession("other") + + token := tserver.createSession("changer") + _, lastID := tserver.pollMessages(token, 0) + + // Try to change another user's mode. + tserver.sendCommand(token, map[string]any{ + commandKey: "MODE", + toKey: "other", + bodyKey: []string{"+w"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 502 ERR_USERSDONTMATCH. + if !findNumeric(msgs, "502") { + t.Fatalf( + "expected ERR_USERSDONTMATCH (502), got %v", + msgs, + ) + } +} + +// 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. +func getNumericBody(msg map[string]any) string { + raw, exists := msg["body"] + if !exists || raw == nil { + return "" + } + + arr, isArr := raw.([]any) + if !isArr || len(arr) == 0 { + return "" + } + + str, isStr := arr[0].(string) + if !isStr { + return "" + } + + return str +} diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index e6c3356..4a591c4 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -345,7 +345,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 @@ -427,7 +430,7 @@ func (c *Conn) handleMode( if strings.HasPrefix(target, "#") { c.handleChannelMode(ctx, msg) } else { - c.handleUserMode(msg) + c.handleUserMode(ctx, msg) } } @@ -687,10 +690,13 @@ 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) { + if !strings.EqualFold(target, c.currentNick()) { c.sendNumeric( irc.ErrUsersDoNotMatch, "Can't change mode for other users", @@ -699,8 +705,48 @@ 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 + 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 + } + + newModes, err := c.svc.ApplyUserMode( + ctx, c.sessionID, msg.Params[1], + ) + if err != nil { + var ircErr *service.IRCError + if errors.As(err, &ircErr) { + c.sendNumeric(ircErr.Code, ircErr.Message) + + return + } + + c.sendNumeric( + irc.ErrUmodeUnknownFlag, + "Unknown MODE flag", + ) + + return + } + + c.sendNumeric(irc.RplUmodeIs, newModes) } // handleNames replies with channel member list. @@ -1292,3 +1338,180 @@ func (c *Conn) handleUserhost( strings.Join(replies, " "), ) } + +// handleVersion replies with the server version string. +func (c *Conn) handleVersion() { + c.sendNumeric( + irc.RplVersion, + c.svc.ServerVersion()+".", c.serverSfx, + "", + ) +} + +// handleAdmin replies with server admin info. +func (c *Conn) handleAdmin() { + srvName := c.serverSfx + + 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() { + for _, line := range c.svc.InfoLines() { + c.sendNumeric(irc.RplInfo, line) + } + + c.sendNumeric( + irc.RplEndOfInfo, + "End of /INFO list", + ) +} + +// handleTime replies with the server's current time. +func (c *Conn) handleTime() { + srvName := c.serverSfx + + 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] + } + + killerNick := c.currentNick() + + if strings.EqualFold(targetNick, killerNick) { + 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 (" + killerNick + + " (" + reason + "))" + + // KillSession broadcasts the QUIT, deletes the session + // and disconnects the victim's wire connection. + c.svc.KillSession( + 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.currentNick(), "*", + nil, body, nil, wallopsSIDs, + ) + } +} diff --git a/internal/ircserver/conn.go b/internal/ircserver/conn.go index fdd6b35..ca593cb 100644 --- a/internal/ircserver/conn.go +++ b/internal/ircserver/conn.go @@ -22,6 +22,7 @@ const ( maxLineLen = 512 readTimeout = 5 * time.Minute writeTimeout = 30 * time.Second + killWriteWindow = 2 * time.Second dnsTimeout = 3 * time.Second pollInterval = 100 * time.Millisecond pingInterval = 90 * time.Second @@ -46,6 +47,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 +69,7 @@ type Conn struct { lastQueueID int64 closed bool + killed bool } func newConn( @@ -97,6 +105,57 @@ func newConn( return conn } +// Disconnect ends the connection for an operator KILL from +// either transport: the victim is sent KILL and ERROR, then +// its socket is closed, which ends serve() and with it the +// relay goroutine. It is called from the killer's goroutine +// and does not wait for the writes, so a victim that has +// stopped reading cannot stall the killer. +func (c *Conn) Disconnect(reason string) { + c.mu.Lock() + + if c.closed { + c.mu.Unlock() + + return + } + + c.closed = true + c.killed = true + nick := c.nick + host := c.hostname + c.mu.Unlock() + + if nick == "" { + nick = "*" + } + + go func() { + c.notifyKilledAndClose(nick, host, reason) + }() +} + +// notifyKilledAndClose sends a killed victim the KILL and +// ERROR lines, each bounded by killWriteWindow, and then +// closes its socket. +func (c *Conn) notifyKilledAndClose( + nick, host, reason string, +) { + defer c.conn.Close() //nolint:errcheck + + c.sendWithin( + killWriteWindow, + FormatMessage( + c.serverSfx, irc.CmdKill, nick, reason, + ), + ) + c.sendWithin( + killWriteWindow, + "ERROR :Closing Link: "+host+ + " ("+reason+")", + ) +} + // buildCommandMap returns a map from IRC command strings // to handler functions. func (c *Conn) buildCommandMap() map[string]cmdHandler { @@ -129,7 +188,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(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, } } @@ -180,7 +245,7 @@ func (c *Conn) serve(ctx context.Context) { c.handleMessage(ctx, msg) - if c.closed { + if c.isClosed() { return } } @@ -189,24 +254,65 @@ 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.sendWithin(writeTimeout, line) +} + +// sendWithin writes a formatted IRC line to the connection +// under the given write deadline. Callers that must not be +// held hostage by an unresponsive peer pass a shorter window +// than writeTimeout. +func (c *Conn) sendWithin( + timeout time.Duration, + line string, +) { + c.writeMu.Lock() + defer c.writeMu.Unlock() + _ = c.conn.SetWriteDeadline( - time.Now().Add(writeTimeout), + time.Now().Add(timeout), ) _, _ = fmt.Fprintf(c.conn, "%s\r\n", line) @@ -387,7 +493,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 } @@ -398,6 +507,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/conn_test.go b/internal/ircserver/conn_test.go new file mode 100644 index 0000000..3347349 --- /dev/null +++ b/internal/ircserver/conn_test.go @@ -0,0 +1,128 @@ +package ircserver_test + +import ( + "bufio" + "log/slog" + "net" + "os" + "strings" + "testing" + "time" + + "sneak.berlin/go/neoirc/internal/config" + "sneak.berlin/go/neoirc/internal/ircserver" +) + +// disconnectBudget is how long Disconnect is allowed to +// take when the victim never reads. It is far below the +// 30s writeTimeout that the old synchronous implementation +// would have burned on each of its two writes. +const disconnectBudget = 2 * time.Second + +// TestDisconnectDoesNotBlockOnUnresponsiveVictim proves +// that an operator KILL cannot be stalled by its target. +// The victim's socket is a net.Pipe, so every write blocks +// until the peer reads and the peer here never does. The +// old implementation performed both notification writes on +// the killer's goroutine, which wedged the operator's own +// serve() loop on the IRC path and the API request on the +// HTTP path for as long as the victim cared to stay silent. +func TestDisconnectDoesNotBlockOnUnresponsiveVictim( + t *testing.T, +) { + t.Parallel() + + serverSide, clientSide := net.Pipe() + + t.Cleanup(func() { + _ = clientSide.Close() + }) + + log := slog.New(slog.NewTextHandler( + os.Stderr, + &slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct + )) + cfg := &config.Config{ //nolint:exhaustruct + ServerName: "test.irc", + } + + victim := ircserver.NewTestConn( + log, cfg, serverSide, "victim", + ) + + returned := make(chan struct{}) + + go func() { + victim.Disconnect("killed by oper") + close(returned) + }() + + select { + case <-returned: + case <-time.After(disconnectBudget): + t.Fatal( + "Disconnect blocked on the victim's socket; " + + "the killer must not be held hostage", + ) + } +} + +// TestDisconnectNotifiesAndClosesVictim is the other half +// of the contract: moving the notification off the killer's +// goroutine must not lose it. A victim that does read gets +// both the KILL and the ERROR line, and then its socket is +// closed so its read loop unblocks. +func TestDisconnectNotifiesAndClosesVictim(t *testing.T) { + t.Parallel() + + serverSide, clientSide := net.Pipe() + + t.Cleanup(func() { + _ = clientSide.Close() + }) + + log := slog.New(slog.NewTextHandler( + os.Stderr, + &slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct + )) + cfg := &config.Config{ //nolint:exhaustruct + ServerName: "test.irc", + } + + victim := ircserver.NewTestConn( + log, cfg, serverSide, "victim", + ) + + lines := make(chan []string, 1) + + go func() { + var got []string + + scanner := bufio.NewScanner(clientSide) + for scanner.Scan() { + got = append(got, scanner.Text()) + } + + lines <- got + }() + + victim.Disconnect("killed by oper") + + var got []string + + select { + case got = <-lines: + case <-time.After(5 * time.Second): + t.Fatal("victim socket was never closed") + } + + joined := strings.Join(got, "\n") + + if !strings.Contains(joined, "KILL victim") { + t.Errorf("missing KILL line, got: %q", joined) + } + + if !strings.Contains(joined, "ERROR :Closing Link:") { + t.Errorf("missing ERROR line, got: %q", joined) + } +} diff --git a/internal/ircserver/export_test.go b/internal/ircserver/export_test.go index 84e870b..5d26699 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 @@ -47,3 +55,21 @@ func (s *Server) Stop() { func (s *Server) Listener() net.Listener { return s.listener } + +// NewTestConn wraps an already-established net.Conn in a +// Conn so tests can drive connection-level behaviour such +// as Disconnect without standing up a whole server. +func NewTestConn( + log *slog.Logger, + cfg *config.Config, + tcpConn net.Conn, + nick string, +) *Conn { + conn := newConn( + context.Background(), tcpConn, log, + nil, nil, cfg, nil, + ) + conn.nick = nick + + return conn +} diff --git a/internal/ircserver/integration_test.go b/internal/ircserver/integration_test.go index 57cd413..aa3eff6 100644 --- a/internal/ircserver/integration_test.go +++ b/internal/ircserver/integration_test.go @@ -762,6 +762,336 @@ 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, 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() + + 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") + + // 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") && + strings.Contains(l, "bob") + }) + assertContains( + t, aliceSeesQuit, "Killed", + "KILL reason in QUIT message", + ) + + // bob must be gone from the channel member list. + alice.send("NAMES #killtest") + + aliceNames := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 366 ") + }) + assertContains( + t, aliceNames, "alice", + "alice still in NAMES after killing bob", + ) + assertNotContains( + t, aliceNames, "bob", + "killed user must not appear in NAMES", + ) + + // ...nor from WHO. + alice.send("WHO #killtest") + + aliceWho := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 315 ") + }) + assertNotContains( + t, aliceWho, "bob", + "killed user must not appear in WHO", + ) + + // 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. @@ -915,3 +1245,80 @@ func TestIntegrationThirdClientObserver(t *testing.T) { "carol receives trio message", ) } + +// assertNoEmptyParam fails if any line contains a doubled +// space, which is what FormatMessage emits for an empty +// non-trailing parameter. A numeric whose server-name +// parameter came out empty is malformed on the wire even +// though it still contains the numeric code, so the other +// assertions in this file would not catch it. +func assertNoEmptyParam( + t *testing.T, + lines []string, + context string, +) { + t.Helper() + + for _, line := range lines { + if strings.Contains(line, " ") { + t.Errorf( + "%s: empty parameter in wire line: %q", + context, line, + ) + } + } +} + +// TestIntegrationDefaultServerNameFallback runs the wire +// server with SERVER_NAME unset, which is the shipped +// default from config.go, and verifies that VERSION, ADMIN +// and TIME fall back to "neoirc" exactly as the HTTP path +// does instead of emitting an empty server-name parameter. +// +// Every other wire test hardcodes ServerName: "test.irc", +// so none of them exercise the default configuration. +func TestIntegrationDefaultServerNameFallback(t *testing.T) { + t.Parallel() + + env := newTestEnvWithServerName(t, "") + + alice := env.dial(t) + alice.register("alice") + + alice.send("VERSION") + + versionReply := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 351 ") + }) + assertNoEmptyParam(t, versionReply, "VERSION") + assertContains( + t, versionReply, "neoirc", + "VERSION falls back to default server name", + ) + + alice.send("ADMIN") + + adminReply := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 259 ") + }) + assertNoEmptyParam(t, adminReply, "ADMIN") + assertContains( + t, adminReply, " 256 alice neoirc ", + "RPL_ADMINME names the default server", + ) + assertContains( + t, adminReply, "admin@neoirc", + "RPL_ADMINEMAIL is a well-formed address", + ) + + alice.send("TIME") + + timeReply := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 391 ") + }) + assertNoEmptyParam(t, timeReply, "TIME") + assertContains( + t, timeReply, " 391 alice neoirc ", + "RPL_TIME names the default server", + ) +} diff --git a/internal/ircserver/relay.go b/internal/ircserver/relay.go index 6d4195f..60ccb74 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: @@ -337,6 +339,18 @@ func (c *Conn) deliverInviteMsg( c.sendFromServer("NOTICE", 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 0ea6f4e..beac374 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -39,6 +39,20 @@ type testEnv struct { func newTestEnv(t *testing.T) *testEnv { t.Helper() + return newTestEnvWithServerName(t, "test.irc") +} + +// newTestEnvWithServerName creates a test environment with +// an explicit SERVER_NAME. Passing "" exercises the shipped +// default from config.go, under which the server must fall +// back to "neoirc" rather than emitting an empty +// server-name parameter. +func newTestEnvWithServerName( + t *testing.T, + serverName string, +) *testEnv { + t.Helper() + dsn := fmt.Sprintf( "file:%s?mode=memory&cache=shared&_journal_mode=WAL", t.Name(), @@ -68,7 +82,7 @@ func newTestEnv(t *testing.T) *testEnv { brk := broker.New() cfg := &config.Config{ //nolint:exhaustruct - ServerName: "test.irc", + ServerName: serverName, MOTD: "Welcome to test IRC", } @@ -115,6 +129,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() @@ -215,6 +310,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( @@ -233,6 +358,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/service/service.go b/internal/service/service.go index 690ce08..c2a74f6 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" ) @@ -33,23 +36,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), } } @@ -59,13 +80,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,6 +905,201 @@ func (s *Service) QueryChannelMode( return modes + modeParams } +// QueryUserMode returns the current user mode string for +// 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, error) { + modes := "+" + + isOper, err := s.db.IsSessionOper(ctx, sessionID) + 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 { + return "", fmt.Errorf( + "query wallops flag: %w", err, + ) + } + + if isWallops { + modes += "w" + } + + return modes, nil +} + +// userModeOp is a single parsed user-mode change collected +// by parseUserModeString before any DB writes happen. +type userModeOp struct { + char rune + adding bool +} + +// ApplyUserMode parses an IRC user-mode string and applies +// the resulting changes atomically. It supports multiple +// sign transitions (e.g. "+w-o", "-w+o", "+o-w+w") and +// rejects malformed input (empty string, no leading sign, +// bare sign with no mode letters, unknown mode letters, +// +o which must be set via OPER) with an IRCError. On +// failure, no persistent change is made: parsing happens +// before any write, and the writes themselves run inside a +// single database transaction that is rolled back whole if +// any statement fails. On success, the resulting mode +// string is returned. +func (s *Service) ApplyUserMode( + ctx context.Context, + sessionID int64, + modeStr string, +) (string, error) { + ops, err := parseUserModeString(modeStr) + if err != nil { + return "", err + } + + wallops, oper, err := collapseUserModeOps(ops) + if err != nil { + return "", err + } + + if err := s.db.SetSessionUserModes( + ctx, sessionID, wallops, oper, + ); err != nil { + s.log.Error( + "apply user modes failed", "error", err, + ) + + return "", fmt.Errorf("apply user modes: %w", err) + } + + return s.QueryUserMode(ctx, sessionID) +} + +// parseUserModeString validates and parses a user-mode +// string into a list of operations. The string must begin +// with '+' or '-'; subsequent '+' / '-' characters flip the +// active sign, and letters between them are applied with +// the current sign. Every letter must be a recognized user +// mode for this server, and '+o' is never allowed via MODE +// (use OPER to become operator). If any character is +// invalid, no operations are returned and an IRCError with +// ERR_UMODEUNKNOWNFLAG (501) is returned. +func parseUserModeString( + modeStr string, +) ([]userModeOp, error) { + unknownFlag := &IRCError{ + Code: irc.ErrUmodeUnknownFlag, + Params: nil, + Message: "Unknown MODE flag", + } + + if modeStr == "" { + return nil, unknownFlag + } + + first := modeStr[0] + if first != '+' && first != '-' { + return nil, unknownFlag + } + + ops := make([]userModeOp, 0, len(modeStr)-1) + adding := true + + for _, modeChar := range modeStr { + switch modeChar { + case '+': + adding = true + case '-': + adding = false + default: + if !isKnownUserModeChar(modeChar) { + return nil, unknownFlag + } + + if modeChar == 'o' && adding { + return nil, unknownFlag + } + + ops = append(ops, userModeOp{ + char: modeChar, adding: adding, + }) + } + } + + if len(ops) == 0 { + return nil, unknownFlag + } + + return ops, nil +} + +// isKnownUserModeChar reports whether the character is a +// recognized user mode letter. +func isKnownUserModeChar(modeChar rune) bool { + switch modeChar { + case 'w', 'o': + return true + default: + return false + } +} + +// collapseUserModeOps reduces an already-parsed operation +// list to the final desired value of each user mode flag. +// A nil result for a flag means the mode string never +// mentioned it, so it must be left untouched. Later +// operations win over earlier ones for the same letter +// (e.g. "+w-w" ends with wallops off), which matches the +// left-to-right semantics of applying each op in turn. +// parseUserModeString must have validated every character +// and sign before this runs; the default branch here is +// defence-in-depth only. +func collapseUserModeOps( + ops []userModeOp, +) (*bool, *bool, error) { + unknownFlag := &IRCError{ + Code: irc.ErrUmodeUnknownFlag, + Params: nil, + Message: "Unknown MODE flag", + } + + var wallops, oper *bool + + for _, modeOp := range ops { + switch modeOp.char { + case 'w': + val := modeOp.adding + wallops = &val + case 'o': + if modeOp.adding { + return nil, nil, unknownFlag + } + + val := false + oper = &val + default: + return nil, nil, unknownFlag + } + } + + return wallops, oper, nil +} + // broadcastNickChange notifies channel peers of a nick // change. func (s *Service) broadcastNickChange( diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 864a8a8..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, @@ -363,3 +365,450 @@ func TestSendChannelMessage_Moderated(t *testing.T) { t.Errorf("operator should be able to send in moderated channel: %v", err) } } + +func TestQueryUserMode(t *testing.T) { + env := newTestEnv(t) + ctx := t.Context() + + sid := createSession(ctx, t, env.db, "alice") + + // Fresh session has no modes. + 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) + } + + // Set wallops. + _ = env.db.SetSessionWallops(ctx, sid, true) + + 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) + } + + // Set oper. + _ = env.db.SetSessionOper(ctx, sid, true) + + 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) + } +} + +// applyUserModeCaseState is the subset of session user-mode +// state the rigorous TestApplyUserMode suite asserts on. It +// mirrors the columns (oper, wallops) that the parser is +// permitted to mutate. +type applyUserModeCaseState struct { + oper bool + wallops bool +} + +// applyUserModeCase describes one rigor-suite case for +// Service.ApplyUserMode: the pre-call DB state, the mode +// string input, and the expected post-call observable state +// (mode string on success, IRC error code on rejection, and +// persisted session state either way). +type applyUserModeCase struct { + name string + initialState applyUserModeCaseState + modeStr string + wantModes string + wantErr bool + wantErrCode irc.IRCMessageType + wantState applyUserModeCaseState +} + +// applyUserModeCases returns every case listed in sneak's +// review of PR #96 plus a few adjacent ones. Split across +// helpers by category so each stays under funlen. +func applyUserModeCases() []applyUserModeCase { + cases := applyUserModeHappyPathCases() + cases = append(cases, applyUserModeSignTransitionCases()...) + cases = append(cases, applyUserModeMalformedCases()...) + cases = append(cases, applyUserModeUnknownLetterCases()...) + + return cases +} + +// applyUserModeHappyPathCases covers valid single-char and +// multi-char-without-sign-transition mode operations. +func applyUserModeHappyPathCases() []applyUserModeCase { + return []applyUserModeCase{ + { + name: "+w from empty", + initialState: applyUserModeCaseState{oper: false, wallops: false}, + modeStr: "+w", + wantModes: "+w", + wantErr: false, + wantErrCode: 0, + wantState: applyUserModeCaseState{oper: false, wallops: true}, + }, + { + name: "-w from +w", + initialState: applyUserModeCaseState{oper: false, wallops: true}, + modeStr: "-w", + wantModes: "+", + wantErr: false, + wantErrCode: 0, + wantState: applyUserModeCaseState{oper: false, wallops: false}, + }, + { + name: "-o from +o", + initialState: applyUserModeCaseState{oper: true, wallops: false}, + modeStr: "-o", + wantModes: "+", + wantErr: false, + wantErrCode: 0, + wantState: applyUserModeCaseState{oper: false, wallops: false}, + }, + { + name: "-wo from +ow", + initialState: applyUserModeCaseState{oper: true, wallops: true}, + modeStr: "-wo", + wantModes: "+", + wantErr: false, + wantErrCode: 0, + wantState: applyUserModeCaseState{oper: false, wallops: false}, + }, + } +} + +// applyUserModeSignTransitionCases covers multi-char mode +// strings where '+' and '-' flip partway through. +o is +// never legal via MODE, so strings containing it must be +// rejected atomically. +func applyUserModeSignTransitionCases() []applyUserModeCase { + return []applyUserModeCase{ + { + name: "+w-o from +o", + initialState: applyUserModeCaseState{oper: true, wallops: false}, + modeStr: "+w-o", + wantModes: "+w", + wantErr: false, + wantErrCode: 0, + wantState: applyUserModeCaseState{oper: false, wallops: true}, + }, + { + // +o is rejected before any op applies; wallops + // stays set. + name: "-w+o always rejects +o", + initialState: applyUserModeCaseState{oper: true, wallops: true}, + modeStr: "-w+o", + wantModes: "", + wantErr: true, + wantErrCode: irc.ErrUmodeUnknownFlag, + wantState: applyUserModeCaseState{oper: true, wallops: true}, + }, + { + // Wallops must NOT be cleared; oper must NOT be + // cleared. Rejection is fully atomic. + name: "+o-w+w rejects because of +o", + initialState: applyUserModeCaseState{oper: true, wallops: true}, + modeStr: "+o-w+w", + wantModes: "", + wantErr: true, + wantErrCode: irc.ErrUmodeUnknownFlag, + wantState: applyUserModeCaseState{oper: true, wallops: true}, + }, + } +} + +// applyUserModeMalformedCases covers inputs that lack a +// leading '+' or '-' and inputs that consist of bare signs +// without mode letters. All must be rejected with no side +// effects. +func applyUserModeMalformedCases() []applyUserModeCase { + return []applyUserModeCase{ + { + name: "w no prefix rejects", + initialState: applyUserModeCaseState{oper: false, wallops: false}, + modeStr: "w", + wantModes: "", + wantErr: true, + wantErrCode: irc.ErrUmodeUnknownFlag, + wantState: applyUserModeCaseState{oper: false, wallops: false}, + }, + { + // Prove wallops is NOT cleared — the whole point + // of sneak's review. + name: "xw no prefix rejects (would have been" + + " silently -w before)", + initialState: applyUserModeCaseState{oper: false, wallops: true}, + modeStr: "xw", + wantModes: "", + wantErr: true, + wantErrCode: irc.ErrUmodeUnknownFlag, + wantState: applyUserModeCaseState{oper: false, wallops: true}, + }, + { + name: "empty string rejects", + initialState: applyUserModeCaseState{oper: false, wallops: false}, + modeStr: "", + wantModes: "", + wantErr: true, + wantErrCode: irc.ErrUmodeUnknownFlag, + wantState: applyUserModeCaseState{oper: false, wallops: false}, + }, + { + name: "bare + rejects", + initialState: applyUserModeCaseState{oper: false, wallops: false}, + modeStr: "+", + wantModes: "", + wantErr: true, + wantErrCode: irc.ErrUmodeUnknownFlag, + wantState: applyUserModeCaseState{oper: false, wallops: false}, + }, + { + name: "bare - rejects", + initialState: applyUserModeCaseState{oper: false, wallops: false}, + modeStr: "-", + wantModes: "", + wantErr: true, + wantErrCode: irc.ErrUmodeUnknownFlag, + wantState: applyUserModeCaseState{oper: false, wallops: false}, + }, + { + name: "+-+ rejects (no mode letters)", + initialState: applyUserModeCaseState{oper: false, wallops: false}, + modeStr: "+-+", + wantModes: "", + wantErr: true, + wantErrCode: irc.ErrUmodeUnknownFlag, + wantState: applyUserModeCaseState{oper: false, wallops: false}, + }, + } +} + +// applyUserModeUnknownLetterCases covers well-formed prefix +// strings that contain unknown mode letters. Rejection must +// be atomic: any valid letters before the invalid one must +// not persist. +func applyUserModeUnknownLetterCases() []applyUserModeCase { + return []applyUserModeCase{ + { + name: "-x+y rejects unknown -x", + initialState: applyUserModeCaseState{oper: false, wallops: false}, + modeStr: "-x+y", + wantModes: "", + wantErr: true, + wantErrCode: irc.ErrUmodeUnknownFlag, + wantState: applyUserModeCaseState{oper: false, wallops: false}, + }, + { + name: "+y-x rejects unknown +y", + initialState: applyUserModeCaseState{oper: false, wallops: false}, + modeStr: "+y-x", + wantModes: "", + wantErr: true, + wantErrCode: irc.ErrUmodeUnknownFlag, + wantState: applyUserModeCaseState{oper: false, wallops: false}, + }, + { + name: "+z unknown mode rejects", + initialState: applyUserModeCaseState{oper: false, wallops: false}, + modeStr: "+z", + wantModes: "", + wantErr: true, + wantErrCode: irc.ErrUmodeUnknownFlag, + wantState: applyUserModeCaseState{oper: false, wallops: false}, + }, + { + // Wallops must NOT be set. + name: "+wz rejects whole thing; +w side effect" + + " must NOT persist", + initialState: applyUserModeCaseState{oper: false, wallops: false}, + modeStr: "+wz", + wantModes: "", + wantErr: true, + wantErrCode: irc.ErrUmodeUnknownFlag, + wantState: applyUserModeCaseState{oper: false, wallops: false}, + }, + { + name: "+wo rejects whole thing; +w side effect" + + " must NOT persist", + initialState: applyUserModeCaseState{oper: false, wallops: false}, + modeStr: "+wo", + wantModes: "", + wantErr: true, + wantErrCode: irc.ErrUmodeUnknownFlag, + wantState: applyUserModeCaseState{oper: false, wallops: false}, + }, + } +} + +// TestApplyUserMode is the rigorous table-driven suite for +// the shared user-mode parser. It covers every case listed +// in sneak's review of PR #96 plus a few adjacent ones. +// Each case asserts the resulting mode string AND the +// persisted session state, to prove that rejected input +// leaves no side effects. +func TestApplyUserMode(t *testing.T) { + for _, testCase := range applyUserModeCases() { + t.Run(testCase.name, func(t *testing.T) { + runApplyUserModeCase(t, testCase) + }) + } +} + +// runApplyUserModeCase executes one applyUserModeCase: seed +// the session state, invoke ApplyUserMode, and verify both +// the returned value and the post-call persisted state. +func runApplyUserModeCase( + t *testing.T, testCase applyUserModeCase, +) { + t.Helper() + + env := newTestEnv(t) + ctx := t.Context() + sid := createSession(ctx, t, env.db, "alice") + + seedApplyUserModeState(ctx, t, env.db, sid, testCase.initialState) + + result, err := env.svc.ApplyUserMode( + ctx, sid, testCase.modeStr, + ) + + verifyApplyUserModeOutcome(t, testCase, result, err) + verifyApplyUserModeState(ctx, t, env.db, sid, testCase.wantState) +} + +// seedApplyUserModeState installs the pre-call session +// state described by initialState. +func seedApplyUserModeState( + ctx context.Context, + t *testing.T, + database *db.Database, + sid int64, + initialState applyUserModeCaseState, +) { + t.Helper() + + if initialState.oper { + if err := database.SetSessionOper( + ctx, sid, true, + ); err != nil { + t.Fatalf("init oper: %v", err) + } + } + + if initialState.wallops { + if err := database.SetSessionWallops( + ctx, sid, true, + ); err != nil { + t.Fatalf("init wallops: %v", err) + } + } +} + +// verifyApplyUserModeOutcome asserts the direct return +// value of ApplyUserMode. It dispatches to the error- or +// success-specific verifier based on wantErr. +func verifyApplyUserModeOutcome( + t *testing.T, + testCase applyUserModeCase, + result string, + err error, +) { + t.Helper() + + if testCase.wantErr { + verifyApplyUserModeError(t, testCase, err) + + return + } + + verifyApplyUserModeSuccess(t, testCase, result, err) +} + +// verifyApplyUserModeError checks that err is a +// *service.IRCError whose code matches wantErrCode. +func verifyApplyUserModeError( + t *testing.T, testCase applyUserModeCase, err error, +) { + t.Helper() + + var ircErr *service.IRCError + if !errors.As(err, &ircErr) { + t.Fatalf("expected IRCError, got %v", err) + } + + if ircErr.Code != testCase.wantErrCode { + t.Errorf( + "code: want %d got %d", + testCase.wantErrCode, ircErr.Code, + ) + } +} + +// verifyApplyUserModeSuccess checks that err is nil and the +// returned mode string matches wantModes. +func verifyApplyUserModeSuccess( + t *testing.T, + testCase applyUserModeCase, + result string, + err error, +) { + t.Helper() + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result != testCase.wantModes { + t.Errorf( + "modes: want %q got %q", + testCase.wantModes, result, + ) + } +} + +// verifyApplyUserModeState asserts the post-call persisted +// session state. This is the atomicity guarantee sneak +// demanded: whether the call succeeded or was rejected, the +// DB must match wantState exactly. +func verifyApplyUserModeState( + ctx context.Context, + t *testing.T, + database *db.Database, + sid int64, + wantState applyUserModeCaseState, +) { + t.Helper() + + gotOper, err := database.IsSessionOper(ctx, sid) + if err != nil { + t.Fatalf("read oper: %v", err) + } + + gotWallops, err := database.IsSessionWallops(ctx, sid) + if err != nil { + t.Fatalf("read wallops: %v", err) + } + + if gotOper != wantState.oper { + t.Errorf( + "oper: want %v got %v", + wantState.oper, gotOper, + ) + } + + if gotWallops != wantState.wallops { + t.Errorf( + "wallops: want %v got %v", + wantState.wallops, gotWallops, + ) + } +} diff --git a/pkg/irc/commands.go b/pkg/irc/commands.go index 60ef85e..4e2e8af 100644 --- a/pkg/irc/commands.go +++ b/pkg/irc/commands.go @@ -2,26 +2,33 @@ package irc // IRC command names (RFC 1459 / RFC 2812). const ( - CmdAway = "AWAY" - CmdInvite = "INVITE" - CmdJoin = "JOIN" - CmdKick = "KICK" - CmdList = "LIST" - CmdLusers = "LUSERS" - CmdMode = "MODE" - CmdMotd = "MOTD" - CmdNames = "NAMES" - CmdNick = "NICK" - CmdNotice = "NOTICE" - CmdOper = "OPER" - CmdPass = "PASS" - CmdPart = "PART" - CmdPing = "PING" - CmdPong = "PONG" - CmdPrivmsg = "PRIVMSG" - CmdQuit = "QUIT" - CmdTopic = "TOPIC" - CmdUser = "USER" - CmdWho = "WHO" - CmdWhois = "WHOIS" + CmdAdmin = "ADMIN" + CmdAway = "AWAY" + CmdInfo = "INFO" + CmdInvite = "INVITE" + CmdJoin = "JOIN" + CmdKick = "KICK" + CmdKill = "KILL" + CmdList = "LIST" + CmdLusers = "LUSERS" + CmdMode = "MODE" + CmdMotd = "MOTD" + CmdNames = "NAMES" + CmdNick = "NICK" + CmdNotice = "NOTICE" + CmdOper = "OPER" + CmdPass = "PASS" + CmdPart = "PART" + CmdPing = "PING" + CmdPong = "PONG" + CmdPrivmsg = "PRIVMSG" + CmdQuit = "QUIT" + CmdTime = "TIME" + CmdTopic = "TOPIC" + CmdUser = "USER" + CmdUserhost = "USERHOST" + CmdVersion = "VERSION" + CmdWallops = "WALLOPS" + CmdWho = "WHO" + CmdWhois = "WHOIS" )