diff --git a/README.md b/README.md index a8fff03..c93cf8b 100644 --- a/README.md +++ b/README.md @@ -222,11 +222,16 @@ Each session has an IRC-style hostmask composed of three parts: Each **client connection** (created at session creation, registration, or login) also stores its own **ip** and **hostname**, allowing the server to track the network origin of each individual client independently from the session. +Client-level IP and hostname are **not displayed to regular users**. They are +only visible to **server operators** (o-line) via `RPL_WHOISACTUALLY` (338) +when the oper performs a WHOIS on a user. The hostmask appears in: - **WHOIS** (`311 RPL_WHOISUSER`) — `params` contains `[nick, username, hostname, "*"]` +- **WHOIS (oper-only)** (`338 RPL_WHOISACTUALLY`) — when the querier is a + server operator, includes the target's current client IP and hostname - **WHO** (`352 RPL_WHOREPLY`) — `params` contains `[channel, username, hostname, server, nick, flags]` @@ -909,7 +914,12 @@ for each channel followed by RPL_LISTEND (323). #### WHOIS — User Information Query information about a user. Returns RPL_WHOISUSER (311), -RPL_WHOISSERVER (312), RPL_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318). +RPL_WHOISSERVER (312), RPL_WHOISOPERATOR (313, if target is oper), +RPL_WHOISIDLE (317), RPL_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318). + +If the querying user is a **server operator** (authenticated via `OPER`), +the response additionally includes RPL_WHOISACTUALLY (338) with the +target's current client IP address and hostname. **C2S:** ```json @@ -944,6 +954,35 @@ LUSERS replies are also sent automatically during connection registration. **IRC reference:** RFC 1459 §4.3.2 +#### OPER — Gain Server Operator Status + +Authenticate as a server operator (o-line). On success, the session gains +oper privileges, which currently means additional information is visible in +WHOIS responses (e.g., target user's current client IP and hostname). + +**C2S:** +```json +{"command": "OPER", "body": ["opername", "operpassword"]} +``` + +**S2C (via message queue on success):** +```json +{"command": "381", "to": "alice", "body": ["You are now an IRC operator"]} +``` + +**Behavior:** + +- `body[0]` is the operator name, `body[1]` is the operator password. +- The server checks against the configured `NEOIRC_OPER_NAME` and + `NEOIRC_OPER_PASSWORD` environment variables. +- On success, the session's `is_oper` flag is set and `381 RPL_YOUREOPER` + is returned. +- On failure (wrong credentials or no o-line configured), `491 ERR_NOOPERHOST` + is returned. +- Oper status persists for the session lifetime. There is no de-oper command. + +**IRC reference:** RFC 1459 §4.1.5 + #### KICK — Kick User (Planned) Remove a user from a channel. @@ -1004,9 +1043,11 @@ the server to the client (never C2S) and use 3-digit string codes in the | `255` | RPL_LUSERME | On connect or LUSERS command | `{"command":"255","to":"alice","body":["I have 5 clients and 1 servers"]}` | | `311` | RPL_WHOISUSER | In response to WHOIS | `{"command":"311","to":"alice","params":["bob","bobident","host.example.com","*"],"body":["bob"]}` | | `312` | RPL_WHOISSERVER | In response to WHOIS | `{"command":"312","to":"alice","params":["bob","neoirc"],"body":["neoirc server"]}` | +| `313` | RPL_WHOISOPERATOR | In WHOIS if target is oper | `{"command":"313","to":"alice","params":["bob"],"body":["is an IRC operator"]}` | | `315` | RPL_ENDOFWHO | End of WHO response | `{"command":"315","to":"alice","params":["#general"],"body":["End of /WHO list"]}` | | `318` | RPL_ENDOFWHOIS | End of WHOIS response | `{"command":"318","to":"alice","params":["bob"],"body":["End of /WHOIS list"]}` | | `319` | RPL_WHOISCHANNELS | In response to WHOIS | `{"command":"319","to":"alice","params":["bob"],"body":["#general #dev"]}` | +| `338` | RPL_WHOISACTUALLY | In WHOIS when querier is oper | `{"command":"338","to":"alice","params":["bob","192.168.1.1"],"body":["is actually using host client.example.com"]}` | | `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General discussion"]}` | | `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` | | `324` | RPL_CHANNELMODEIS | In response to channel MODE query | `{"command":"324","to":"alice","params":["#general","+n"]}` | @@ -1019,6 +1060,7 @@ the server to the client (never C2S) and use 3-digit string codes in the | `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` | | `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"375","to":"alice","body":["- neoirc-server Message of the Day -"]}` | | `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"376","to":"alice","body":["End of /MOTD command"]}` | +| `381` | RPL_YOUREOPER | Successful OPER auth | `{"command":"381","to":"alice","body":["You are now an IRC operator"]}` | | `401` | ERR_NOSUCHNICK | DM to nonexistent nick | `{"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}` | | `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` | | `421` | ERR_UNKNOWNCOMMAND | Unrecognized command | `{"command":"421","to":"alice","params":["FOO"],"body":["Unknown command"]}` | @@ -1027,6 +1069,7 @@ the server to the client (never C2S) and use 3-digit string codes in the | `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` | | `461` | ERR_NEEDMOREPARAMS | Missing required fields | `{"command":"461","to":"alice","params":["JOIN"],"body":["Not enough parameters"]}` | | `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` | +| `491` | ERR_NOOPERHOST | Failed OPER auth | `{"command":"491","to":"alice","body":["No O-lines for your host"]}` | **Note:** Numeric replies are now implemented. All IRC command responses (success and error) are delivered as numeric replies through the message queue. @@ -1381,6 +1424,7 @@ reference with all required and optional fields. | `WHOIS` | `to` or `body` | | 200 OK | | `WHO` | `to` | | 200 OK | | `LUSERS` | | | 200 OK | +| `OPER` | `body` | | 200 OK | | `QUIT` | | `body` | 200 OK | | `PING` | | | 200 OK | @@ -1409,6 +1453,7 @@ auth tokens (401), and server errors (500). | 433 | ERR_NICKNAMEINUSE | NICK target is taken | | 442 | ERR_NOTONCHANNEL | Not a member of the target channel | | 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) | +| 491 | ERR_NOOPERHOST | Failed OPER authentication | **IRC numeric success replies (delivered via message queue):** @@ -1426,9 +1471,11 @@ auth tokens (401), and server errors (500). | 255 | RPL_LUSERME | On connect or LUSERS command | | 311 | RPL_WHOISUSER | WHOIS user info | | 312 | RPL_WHOISSERVER | WHOIS server info | +| 313 | RPL_WHOISOPERATOR | WHOIS target is oper | | 315 | RPL_ENDOFWHO | End of WHO list | | 318 | RPL_ENDOFWHOIS | End of WHOIS list | | 319 | RPL_WHOISCHANNELS | WHOIS channels list | +| 338 | RPL_WHOISACTUALLY | WHOIS client IP (oper-only) | | 322 | RPL_LIST | Channel in LIST response | | 323 | RPL_LISTEND | End of LIST | | 324 | RPL_CHANNELMODEIS | Channel mode query response | @@ -1441,6 +1488,7 @@ auth tokens (401), and server errors (500). | 375 | RPL_MOTDSTART | Start of MOTD | | 372 | RPL_MOTD | MOTD line | | 376 | RPL_ENDOFMOTD | End of MOTD | +| 381 | RPL_YOUREOPER | Successful OPER authentication | ### GET /api/v1/history — Message History @@ -1981,6 +2029,8 @@ The database schema is managed via embedded SQL migration files in | `nick` | TEXT | Unique nick | | `username` | TEXT | IRC ident/username portion of the hostmask (defaults to nick) | | `hostname` | TEXT | Reverse DNS hostname of the connecting client IP | +| `ip` | TEXT | Real IP address of the session creator | +| `is_oper` | INTEGER | Server operator (o-line) status (0 = no, 1 = yes) | | `password_hash`| TEXT | bcrypt hash (empty string for anonymous sessions) | | `signing_key` | TEXT | Public signing key (empty string if unset) | | `away_message` | TEXT | Away message (empty string if not away) | @@ -1994,6 +2044,8 @@ The database schema is managed via embedded SQL migration files in | `uuid` | TEXT | Unique client UUID | | `session_id`| INTEGER | FK → sessions.id (cascade delete) | | `token` | TEXT | Unique auth token (SHA-256 hash of 64 hex chars) | +| `ip` | TEXT | Real IP address of this client connection | +| `hostname` | TEXT | Reverse DNS hostname of this client connection | | `created_at`| DATETIME | Client creation time | | `last_seen` | DATETIME | Last API request time | @@ -2091,6 +2143,8 @@ directory is also loaded automatically via | `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. | | `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint | | `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. | +| `NEOIRC_OPER_NAME` | string | `""` | Server operator (o-line) username. Both name and password must be set to enable OPER. | +| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. | | `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) | ### Example `.env` file diff --git a/internal/config/config.go b/internal/config/config.go index da29f1e..a7755b5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,6 +46,8 @@ type Config struct { FederationKey string SessionIdleTimeout string HashcashBits int + OperName string + OperPassword string params *Params log *slog.Logger } @@ -78,6 +80,8 @@ func New( viper.SetDefault("FEDERATION_KEY", "") viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h") viper.SetDefault("NEOIRC_HASHCASH_BITS", "20") + viper.SetDefault("NEOIRC_OPER_NAME", "") + viper.SetDefault("NEOIRC_OPER_PASSWORD", "") err := viper.ReadInConfig() if err != nil { @@ -104,6 +108,8 @@ func New( FederationKey: viper.GetString("FEDERATION_KEY"), SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"), HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"), + OperName: viper.GetString("NEOIRC_OPER_NAME"), + OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"), log: log, params: ¶ms, } diff --git a/internal/db/queries.go b/internal/db/queries.go index c5abbe8..839591b 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -298,6 +298,75 @@ func (database *Database) GetClientHostInfo( return &info, nil } +// SetSessionOper sets the is_oper flag on a session. +func (database *Database) SetSessionOper( + ctx context.Context, + sessionID int64, + isOper bool, +) error { + val := 0 + if isOper { + val = 1 + } + + _, err := database.conn.ExecContext( + ctx, + `UPDATE sessions SET is_oper = ? WHERE id = ?`, + val, sessionID, + ) + if err != nil { + return fmt.Errorf("set session oper: %w", err) + } + + return nil +} + +// IsSessionOper returns whether the session has oper +// status. +func (database *Database) IsSessionOper( + ctx context.Context, + sessionID int64, +) (bool, error) { + var isOper int + + err := database.conn.QueryRowContext( + ctx, + `SELECT is_oper FROM sessions WHERE id = ?`, + sessionID, + ).Scan(&isOper) + if err != nil { + return false, fmt.Errorf( + "check session oper: %w", err, + ) + } + + return isOper != 0, nil +} + +// GetLatestClientForSession returns the IP and hostname +// of the most recently created client for a session. +func (database *Database) GetLatestClientForSession( + ctx context.Context, + sessionID int64, +) (*ClientHostInfo, error) { + var info ClientHostInfo + + err := database.conn.QueryRowContext( + ctx, + `SELECT ip, hostname FROM clients + WHERE session_id = ? + ORDER BY created_at DESC LIMIT 1`, + sessionID, + ).Scan(&info.IP, &info.Hostname) + if err != nil { + return nil, fmt.Errorf( + "get latest client for session: %w", err, + ) + } + + return &info, nil +} + // GetChannelByName returns the channel ID for a name. func (database *Database) GetChannelByName( ctx context.Context, @@ -951,6 +1020,26 @@ func (database *Database) GetUserCount( return count, nil } +// GetOperCount returns the number of sessions with oper +// status. +func (database *Database) GetOperCount( + ctx context.Context, +) (int64, error) { + var count int64 + + err := database.conn.QueryRowContext( + ctx, + "SELECT COUNT(*) FROM sessions WHERE is_oper = 1", + ).Scan(&count) + if err != nil { + return 0, fmt.Errorf( + "get oper count: %w", err, + ) + } + + return count, nil +} + // ClientCountForSession returns the number of clients // belonging to a session. func (database *Database) ClientCountForSession( diff --git a/internal/db/queries_test.go b/internal/db/queries_test.go index 938bb0b..e270bdb 100644 --- a/internal/db/queries_test.go +++ b/internal/db/queries_test.go @@ -887,3 +887,133 @@ func TestEnqueueToClient(t *testing.T) { t.Fatalf("expected 1, got %d", len(msgs)) } } + +func TestSetAndCheckSessionOper(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + sessionID, _, _, err := database.CreateSession( + ctx, "opernick", "", "", "", + ) + if err != nil { + t.Fatal(err) + } + + // Initially not oper. + isOper, err := database.IsSessionOper(ctx, sessionID) + if err != nil { + t.Fatal(err) + } + + if isOper { + t.Fatal("expected session not to be oper") + } + + // Set oper. + err = database.SetSessionOper(ctx, sessionID, true) + if err != nil { + t.Fatal(err) + } + + isOper, err = database.IsSessionOper(ctx, sessionID) + if err != nil { + t.Fatal(err) + } + + if !isOper { + t.Fatal("expected session to be oper") + } + + // Unset oper. + err = database.SetSessionOper(ctx, sessionID, false) + if err != nil { + t.Fatal(err) + } + + isOper, err = database.IsSessionOper(ctx, sessionID) + if err != nil { + t.Fatal(err) + } + + if isOper { + t.Fatal("expected session not to be oper") + } +} + +func TestGetLatestClientForSession(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + sessionID, _, _, err := database.CreateSession( + ctx, "clientnick", "", "", "10.0.0.1", + ) + if err != nil { + t.Fatal(err) + } + + clientInfo, err := database.GetLatestClientForSession( + ctx, sessionID, + ) + if err != nil { + t.Fatal(err) + } + + if clientInfo.IP != "10.0.0.1" { + t.Fatalf( + "expected IP 10.0.0.1, got %s", + clientInfo.IP, + ) + } +} + +func TestGetOperCount(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + // Create two sessions. + sid1, _, _, err := database.CreateSession( + ctx, "user1", "", "", "", + ) + if err != nil { + t.Fatal(err) + } + + sid2, _, _, err := database.CreateSession( + ctx, "user2", "", "", "", + ) + _ = sid2 + if err != nil { + t.Fatal(err) + } + + // Initially zero opers. + count, err := database.GetOperCount(ctx) + if err != nil { + t.Fatal(err) + } + + if count != 0 { + t.Fatalf("expected 0 opers, got %d", count) + } + + // Set one as oper. + err = database.SetSessionOper(ctx, sid1, true) + if err != nil { + t.Fatal(err) + } + + count, err = database.GetOperCount(ctx) + if err != nil { + t.Fatal(err) + } + + if count != 1 { + t.Fatalf("expected 1 oper, got %d", count) + } +} diff --git a/internal/db/schema/001_initial.sql b/internal/db/schema/001_initial.sql index e5971ea..f58ae29 100644 --- a/internal/db/schema/001_initial.sql +++ b/internal/db/schema/001_initial.sql @@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS sessions ( username TEXT NOT NULL DEFAULT '', hostname TEXT NOT NULL DEFAULT '', ip TEXT NOT NULL DEFAULT '', + is_oper 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 418cc0e..debacf4 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -460,9 +460,19 @@ func (hdlr *Handlers) deliverLusers( ) // 252 RPL_LUSEROP + operCount, operErr := hdlr.params.Database. + GetOperCount(ctx) + if operErr != nil { + hdlr.log.Error( + "lusers oper count", "error", operErr, + ) + + operCount = 0 + } + hdlr.enqueueNumeric( ctx, clientID, irc.RplLuserOp, nick, - []string{"0"}, + []string{strconv.FormatInt(operCount, 10)}, "operator(s) online", ) @@ -992,6 +1002,11 @@ func (hdlr *Handlers) dispatchCommand( hdlr.handleQuit( writer, request, sessionID, nick, body, ) + case irc.CmdOper: + hdlr.handleOper( + writer, request, + sessionID, clientID, nick, bodyLines, + ) case irc.CmdMotd, irc.CmdPing: hdlr.dispatchInfoCommand( writer, request, @@ -2198,31 +2213,89 @@ func (hdlr *Handlers) executeWhois( nick, queryNick string, ) { ctx := request.Context() - srvName := hdlr.serverName() targetSID, err := hdlr.params.Database.GetSessionByNick( ctx, queryNick, ) if err != nil { - hdlr.enqueueNumeric( - ctx, clientID, irc.ErrNoSuchNick, nick, - []string{queryNick}, - "No such nick/channel", + hdlr.whoisNotFound( + ctx, writer, request, + sessionID, clientID, nick, queryNick, ) - hdlr.enqueueNumeric( - ctx, clientID, irc.RplEndOfWhois, nick, - []string{queryNick}, - "End of /WHOIS list", - ) - hdlr.broker.Notify(sessionID) - hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, - http.StatusOK) return } - // Look up username and hostname for the target. + hdlr.deliverWhoisUser( + ctx, clientID, nick, queryNick, targetSID, + ) + + // 313 RPL_WHOISOPERATOR — show if target is oper. + hdlr.deliverWhoisOperator( + ctx, clientID, nick, queryNick, targetSID, + ) + + hdlr.deliverWhoisIdle( + ctx, clientID, nick, queryNick, targetSID, + ) + + hdlr.deliverWhoisChannels( + ctx, clientID, nick, queryNick, targetSID, + ) + + // 338 RPL_WHOISACTUALLY — oper-only. + hdlr.deliverWhoisActually( + ctx, clientID, nick, queryNick, + sessionID, targetSID, + ) + + hdlr.enqueueNumeric( + ctx, clientID, irc.RplEndOfWhois, nick, + []string{queryNick}, + "End of /WHOIS list", + ) + + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// whoisNotFound sends the error+end numerics when the +// target nick is not found. +func (hdlr *Handlers) whoisNotFound( + ctx context.Context, + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, queryNick string, +) { + hdlr.enqueueNumeric( + ctx, clientID, irc.ErrNoSuchNick, nick, + []string{queryNick}, + "No such nick/channel", + ) + hdlr.enqueueNumeric( + ctx, clientID, irc.RplEndOfWhois, nick, + []string{queryNick}, + "End of /WHOIS list", + ) + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// deliverWhoisUser sends RPL_WHOISUSER (311) and +// RPL_WHOISSERVER (312). +func (hdlr *Handlers) deliverWhoisUser( + ctx context.Context, + clientID int64, + nick, queryNick string, + targetSID int64, +) { + srvName := hdlr.serverName() + username := queryNick hostname := srvName @@ -2238,41 +2311,38 @@ func (hdlr *Handlers) executeWhois( } } - // 311 RPL_WHOISUSER hdlr.enqueueNumeric( ctx, clientID, irc.RplWhoisUser, nick, []string{queryNick, username, hostname, "*"}, queryNick, ) - // 312 RPL_WHOISSERVER hdlr.enqueueNumeric( ctx, clientID, irc.RplWhoisServer, nick, []string{queryNick, srvName}, "neoirc server", ) +} - // 317 RPL_WHOISIDLE - hdlr.deliverWhoisIdle( - ctx, clientID, nick, queryNick, targetSID, - ) +// deliverWhoisOperator sends RPL_WHOISOPERATOR (313) if +// the target has server oper status. +func (hdlr *Handlers) deliverWhoisOperator( + ctx context.Context, + clientID int64, + nick, queryNick string, + targetSID int64, +) { + targetIsOper, err := hdlr.params.Database. + IsSessionOper(ctx, targetSID) + if err != nil || !targetIsOper { + return + } - // 319 RPL_WHOISCHANNELS - hdlr.deliverWhoisChannels( - ctx, clientID, nick, queryNick, targetSID, - ) - - // 318 RPL_ENDOFWHOIS hdlr.enqueueNumeric( - ctx, clientID, irc.RplEndOfWhois, nick, + ctx, clientID, irc.RplWhoisOperator, nick, []string{queryNick}, - "End of /WHOIS list", + "is an IRC operator", ) - - hdlr.broker.Notify(sessionID) - hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, - http.StatusOK) } func (hdlr *Handlers) deliverWhoisChannels( @@ -2300,6 +2370,44 @@ func (hdlr *Handlers) deliverWhoisChannels( ) } +// deliverWhoisActually sends RPL_WHOISACTUALLY (338) +// with the target's current client IP and hostname, but +// only when the querying session has server oper status +// (o-line). Non-opers see nothing extra. +func (hdlr *Handlers) deliverWhoisActually( + ctx context.Context, + clientID int64, + nick, queryNick string, + querierSID, targetSID int64, +) { + isOper, err := hdlr.params.Database.IsSessionOper( + ctx, querierSID, + ) + if err != nil || !isOper { + return + } + + clientInfo, clErr := hdlr.params.Database. + GetLatestClientForSession(ctx, targetSID) + if clErr != nil { + return + } + + actualHost := clientInfo.Hostname + if actualHost == "" { + actualHost = clientInfo.IP + } + + hdlr.enqueueNumeric( + ctx, clientID, irc.RplWhoisActually, nick, + []string{ + queryNick, + clientInfo.IP, + }, + "is actually using host "+actualHost, + ) +} + // handleWho handles the WHO command. func (hdlr *Handlers) handleWho( writer http.ResponseWriter, @@ -2687,6 +2795,74 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc { // handleAway handles the AWAY command. An empty body // clears the away status; a non-empty body sets it. +func (hdlr *Handlers) handleOper( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, + bodyLines func() []string, +) { + ctx := request.Context() + + lines := bodyLines() + if len(lines) < 2 { //nolint:mnd // name + password + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNeedMoreParams, nick, + []string{irc.CmdOper}, + "Not enough parameters", + ) + + return + } + + operName := lines[0] + operPass := lines[1] + + cfgName := hdlr.params.Config.OperName + cfgPass := hdlr.params.Config.OperPassword + + if cfgName == "" || cfgPass == "" || + operName != cfgName || operPass != cfgPass { + hdlr.enqueueNumeric( + ctx, clientID, irc.ErrNoOperHost, nick, + nil, "No O-lines for your host", + ) + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "error"}, + http.StatusOK) + + return + } + + err := hdlr.params.Database.SetSessionOper( + ctx, sessionID, true, + ) + if err != nil { + hdlr.log.Error( + "set oper failed", "error", err, + ) + hdlr.respondError( + writer, request, "internal error", + http.StatusInternalServerError, + ) + + return + } + + // 381 RPL_YOUREOPER + hdlr.enqueueNumeric( + ctx, clientID, irc.RplYoureOper, nick, + nil, "You are now an IRC operator", + ) + + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + func (hdlr *Handlers) handleAway( writer http.ResponseWriter, request *http.Request, diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index eb72197..eb5742b 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -2532,3 +2532,315 @@ func assertNamesHostmask( targetNick, msgs, ) } + +const testOperName = "admin" +const testOperPassword = "secretpass" + +// newTestServerWithOper creates a test server with oper +// credentials configured (admin / secretpass). +func newTestServerWithOper( + t *testing.T, +) *testServer { + t.Helper() + + dbPath := filepath.Join( + t.TempDir(), "test.db", + ) + + dbURL := "file:" + dbPath + + "?_journal_mode=WAL&_busy_timeout=5000" + + var srv *server.Server + + app := fxtest.New(t, + fx.Provide( + newTestGlobals, + logger.New, + func( + lifecycle fx.Lifecycle, + globs *globals.Globals, + log *logger.Logger, + ) (*config.Config, error) { + cfg, err := config.New( + lifecycle, config.Params{ //nolint:exhaustruct + Globals: globs, Logger: log, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "test config: %w", err, + ) + } + + cfg.DBURL = dbURL + cfg.Port = 0 + cfg.HashcashBits = 0 + cfg.OperName = testOperName + cfg.OperPassword = testOperPassword + + return cfg, nil + }, + newTestDB, + stats.New, + newTestHealthcheck, + newTestMiddleware, + newTestHandlers, + newTestServerFx, + ), + fx.Populate(&srv), + ) + + const startupDelay = 100 * time.Millisecond + + app.RequireStart() + time.Sleep(startupDelay) + + httpSrv := httptest.NewServer(srv) + + t.Cleanup(func() { + httpSrv.Close() + app.RequireStop() + }) + + return &testServer{ + httpServer: httpSrv, + t: t, + fxApp: app, + } +} + +func TestOperCommandSuccess(t *testing.T) { + tserver := newTestServerWithOper(t) + + token := tserver.createSession("operuser") + _, lastID := tserver.pollMessages(token, 0) + + // Send OPER command. + tserver.sendCommand(token, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, testOperPassword}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 381 RPL_YOUREOPER. + if !findNumeric(msgs, "381") { + t.Fatalf( + "expected RPL_YOUREOPER (381), got %v", + msgs, + ) + } +} + +func TestOperCommandFailure(t *testing.T) { + tserver := newTestServerWithOper(t) + + token := tserver.createSession("badoper") + _, lastID := tserver.pollMessages(token, 0) + + // Send OPER with wrong password. + tserver.sendCommand(token, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, "wrongpass"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 491 ERR_NOOPERHOST. + if !findNumeric(msgs, "491") { + t.Fatalf( + "expected ERR_NOOPERHOST (491), got %v", + msgs, + ) + } +} + +func TestOperCommandNeedMoreParams(t *testing.T) { + tserver := newTestServerWithOper(t) + + token := tserver.createSession("shortoper") + _, lastID := tserver.pollMessages(token, 0) + + // Send OPER with only one parameter. + tserver.sendCommand(token, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 461 ERR_NEEDMOREPARAMS. + if !findNumeric(msgs, "461") { + t.Fatalf( + "expected ERR_NEEDMOREPARAMS (461), got %v", + msgs, + ) + } +} + +func TestOperWhoisShowsClientInfo(t *testing.T) { + tserver := newTestServerWithOper(t) + + // Create a target user. + _ = tserver.createSession("target") + + // Create an oper user. + operToken := tserver.createSession("theoper") + _, lastID := tserver.pollMessages(operToken, 0) + + // Authenticate as oper. + tserver.sendCommand(operToken, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, testOperPassword}, + }) + + var msgs []map[string]any + + msgs, lastID = tserver.pollMessages(operToken, lastID) + + if !findNumeric(msgs, "381") { + t.Fatalf( + "expected RPL_YOUREOPER (381), got %v", + msgs, + ) + } + + // Now WHOIS the target. + tserver.sendCommand(operToken, map[string]any{ + commandKey: "WHOIS", + toKey: "target", + }) + + msgs, _ = tserver.pollMessages(operToken, lastID) + + // Expect 338 RPL_WHOISACTUALLY with client IP. + whoisActually := findNumericWithParams(msgs, "338") + if whoisActually == nil { + t.Fatalf( + "expected RPL_WHOISACTUALLY (338) for "+ + "oper WHOIS, got %v", + msgs, + ) + } + + params := getNumericParams(whoisActually) + if len(params) < 2 { + t.Fatalf( + "expected at least 2 params in 338, "+ + "got %v", + params, + ) + } + + // First param should be the target nick. + if params[0] != "target" { + t.Fatalf( + "expected first param 'target', got %s", + params[0], + ) + } + + // Second param should be a non-empty IP. + if params[1] == "" { + t.Fatal("expected non-empty IP in 338 params") + } +} + +func TestNonOperWhoisHidesClientInfo(t *testing.T) { + tserver := newTestServerWithOper(t) + + // Create a target user. + _ = tserver.createSession("hidden") + + // Create a regular (non-oper) user. + regToken := tserver.createSession("regular") + _, lastID := tserver.pollMessages(regToken, 0) + + // WHOIS the target without oper status. + tserver.sendCommand(regToken, map[string]any{ + commandKey: "WHOIS", + toKey: "hidden", + }) + + msgs, _ := tserver.pollMessages(regToken, lastID) + + // Should NOT see 338 RPL_WHOISACTUALLY. + if findNumeric(msgs, "338") { + t.Fatalf( + "non-oper should not see "+ + "RPL_WHOISACTUALLY (338), got %v", + msgs, + ) + } + + // But should see 311 RPL_WHOISUSER (normal WHOIS). + if !findNumeric(msgs, "311") { + t.Fatalf( + "expected RPL_WHOISUSER (311), got %v", + msgs, + ) + } +} + +func TestWhoisShowsOperatorStatus(t *testing.T) { + tserver := newTestServerWithOper(t) + + // Create oper user and authenticate. + operToken := tserver.createSession("iamoper") + _, lastID := tserver.pollMessages(operToken, 0) + + tserver.sendCommand(operToken, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, testOperPassword}, + }) + + msgs, _ := tserver.pollMessages(operToken, lastID) + + if !findNumeric(msgs, "381") { + t.Fatalf("expected 381, got %v", msgs) + } + + // Another user does WHOIS on the oper. + queryToken := tserver.createSession("asker") + _, queryLastID := tserver.pollMessages(queryToken, 0) + + tserver.sendCommand(queryToken, map[string]any{ + commandKey: "WHOIS", + toKey: "iamoper", + }) + + msgs, _ = tserver.pollMessages(queryToken, queryLastID) + + // Should see 313 RPL_WHOISOPERATOR. + if !findNumeric(msgs, "313") { + t.Fatalf( + "expected RPL_WHOISOPERATOR (313) in "+ + "WHOIS of oper, got %v", + msgs, + ) + } +} + +func TestOperNoOlineConfigured(t *testing.T) { + // Standard test server has no oper configured. + tserver := newTestServer(t) + + token := tserver.createSession("nooline") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, "password"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Should get 491 since no o-line is configured. + if !findNumeric(msgs, "491") { + t.Fatalf( + "expected ERR_NOOPERHOST (491) when no "+ + "o-line configured, got %v", + msgs, + ) + } +} diff --git a/pkg/irc/commands.go b/pkg/irc/commands.go index fc2191b..73e327b 100644 --- a/pkg/irc/commands.go +++ b/pkg/irc/commands.go @@ -11,6 +11,7 @@ const ( CmdNames = "NAMES" CmdNick = "NICK" CmdNotice = "NOTICE" + CmdOper = "OPER" CmdPart = "PART" CmdPing = "PING" CmdPong = "PONG" diff --git a/pkg/irc/numerics.go b/pkg/irc/numerics.go index b71ebc2..b7bba22 100644 --- a/pkg/irc/numerics.go +++ b/pkg/irc/numerics.go @@ -132,6 +132,7 @@ const ( RplNoTopic IRCMessageType = 331 RplTopic IRCMessageType = 332 RplTopicWhoTime IRCMessageType = 333 + RplWhoisActually IRCMessageType = 338 RplInviting IRCMessageType = 341 RplSummoning IRCMessageType = 342 RplInviteList IRCMessageType = 346 @@ -295,6 +296,7 @@ var names = map[IRCMessageType]string{ RplNoTopic: "RPL_NOTOPIC", RplTopic: "RPL_TOPIC", RplTopicWhoTime: "RPL_TOPICWHOTIME", + RplWhoisActually: "RPL_WHOISACTUALLY", RplInviting: "RPL_INVITING", RplSummoning: "RPL_SUMMONING", RplInviteList: "RPL_INVITELIST",