diff --git a/README.md b/README.md index 37525cd..c93cf8b 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,37 @@ removal. Identity verification at the message layer via cryptographic signatures (see [Security Model](#security-model)) remains independent of account registration. +### Hostmask (nick!user@host) + +Each session has an IRC-style hostmask composed of three parts: + +- **nick** — the user's current nick (changes with `NICK` command) +- **username** — an ident-like identifier set at session creation (optional + `username` field in the session/register request; defaults to the nick) +- **hostname** — automatically resolved via reverse DNS of the connecting + client's IP address at session creation time +- **ip** — the real IP address of the session creator, extracted from + `X-Forwarded-For`, `X-Real-IP`, or `RemoteAddr` + +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]` + +The hostmask format (`nick!user@host`) is stored for future use in ban matching +(`+b` mode) and other access control features. + ### Nick Semantics - Nicks are **unique per server at any point in time** — two sessions cannot @@ -883,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 @@ -918,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. @@ -976,23 +1041,26 @@ the server to the client (never C2S) and use 3-digit string codes in the | `252` | RPL_LUSEROP | On connect or LUSERS command | `{"command":"252","to":"alice","params":["0"],"body":["operator(s) online"]}` | | `254` | RPL_LUSERCHANNELS | On connect or LUSERS command | `{"command":"254","to":"alice","params":["3"],"body":["channels formed"]}` | | `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","bob","neoirc","*"],"body":["bob"]}` | +| `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"]}` | | `329` | RPL_CREATIONTIME | After channel MODE query | `{"command":"329","to":"alice","params":["#general","1709251200"]}` | | `331` | RPL_NOTOPIC | Channel has no topic (on JOIN) | `{"command":"331","to":"alice","params":["#general"],"body":["No topic is set"]}` | | `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` | -| `352` | RPL_WHOREPLY | In response to WHO | `{"command":"352","to":"alice","params":["#general","bob","neoirc","neoirc","bob","H"],"body":["0 bob"]}` | -| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["@op1 alice bob +voiced1"]}` | +| `352` | RPL_WHOREPLY | In response to WHO | `{"command":"352","to":"alice","params":["#general","bobident","host.example.com","neoirc","bob","H"],"body":["0 bob"]}` | +| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["op1!op1@host1 alice!alice@host2 bob!bob@host3"]}` | | `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` | | `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"]}` | @@ -1001,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. @@ -1056,14 +1125,20 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field. **Request Body:** ```json -{"nick": "alice", "pow_token": "1:20:260310:neoirc::3a2f1"} +{"nick": "alice", "username": "alice", "pow_token": "1:20:260310:neoirc::3a2f1"} ``` | Field | Type | Required | Constraints | |------------|--------|-------------|-------------| | `nick` | string | Yes | 1–32 characters, must be unique on the server | +| `username` | string | No | 1–32 characters, IRC ident-style. Defaults to nick if omitted. | | `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) | +The `username` field sets the user portion of the IRC hostmask +(`nick!user@host`). The hostname is automatically resolved via reverse DNS of +the connecting client's IP address at session creation time. Together these form +the hostmask used in WHOIS, WHO, and future ban matching (`+b`). + **Response:** `201 Created` ```json { @@ -1084,6 +1159,7 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field. | Status | Error | When | |--------|-------|------| | 400 | `nick must be 1-32 characters` | Empty or too-long nick | +| 400 | `invalid username format` | Username doesn't match allowed format | | 402 | `hashcash proof-of-work required` | Missing `pow_token` field in request body when hashcash is enabled | | 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) | | 409 | `nick already taken` | Another active session holds this nick | @@ -1105,14 +1181,18 @@ remains active. **Request Body:** ```json -{"nick": "alice", "password": "mypassword"} +{"nick": "alice", "username": "alice", "password": "mypassword"} ``` | Field | Type | Required | Constraints | |------------|--------|----------|-------------| | `nick` | string | Yes | 1–32 characters, must be unique on the server | +| `username` | string | No | 1–32 characters, IRC ident-style. Defaults to nick if omitted. | | `password` | string | Yes | Minimum 8 characters | +The `username` and hostname (auto-resolved via reverse DNS) form the IRC +hostmask (`nick!user@host`) shown in WHOIS and WHO responses. + **Response:** `201 Created` ```json { @@ -1133,6 +1213,7 @@ remains active. | Status | Error | When | |--------|-------|------| | 400 | `invalid nick format` | Nick doesn't match allowed format | +| 400 | `invalid username format` | Username doesn't match allowed format | | 400 | `password must be at least 8 characters` | Password too short | | 409 | `nick already taken` | Another active session holds this nick | @@ -1343,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 | @@ -1371,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):** @@ -1388,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 | @@ -1403,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 @@ -1941,6 +2027,10 @@ The database schema is managed via embedded SQL migration files in | `id` | INTEGER | Primary key (auto-increment) | | `uuid` | TEXT | Unique session UUID | | `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) | @@ -1954,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 | @@ -2051,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/auth.go b/internal/db/auth.go index 7bf18bd..0367ace 100644 --- a/internal/db/auth.go +++ b/internal/db/auth.go @@ -20,8 +20,12 @@ var errNoPassword = errors.New( // and returns session ID, client ID, and token. func (database *Database) RegisterUser( ctx context.Context, - nick, password string, + nick, password, username, hostname, remoteIP string, ) (int64, int64, string, error) { + if username == "" { + username = nick + } + hash, err := bcrypt.GenerateFromPassword( []byte(password), bcryptCost, ) @@ -50,10 +54,11 @@ func (database *Database) RegisterUser( res, err := transaction.ExecContext(ctx, `INSERT INTO sessions - (uuid, nick, password_hash, - created_at, last_seen) - VALUES (?, ?, ?, ?, ?)`, - sessionUUID, nick, string(hash), now, now) + (uuid, nick, username, hostname, ip, + password_hash, created_at, last_seen) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + sessionUUID, nick, username, hostname, + remoteIP, string(hash), now, now) if err != nil { _ = transaction.Rollback() @@ -68,10 +73,11 @@ func (database *Database) RegisterUser( clientRes, err := transaction.ExecContext(ctx, `INSERT INTO clients - (uuid, session_id, token, + (uuid, session_id, token, ip, hostname, created_at, last_seen) - VALUES (?, ?, ?, ?, ?)`, - clientUUID, sessionID, tokenHash, now, now) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + clientUUID, sessionID, tokenHash, + remoteIP, hostname, now, now) if err != nil { _ = transaction.Rollback() @@ -96,7 +102,7 @@ func (database *Database) RegisterUser( // client token. func (database *Database) LoginUser( ctx context.Context, - nick, password string, + nick, password, remoteIP, hostname string, ) (int64, int64, string, error) { var ( sessionID int64 @@ -143,10 +149,11 @@ func (database *Database) LoginUser( res, err := database.conn.ExecContext(ctx, `INSERT INTO clients - (uuid, session_id, token, + (uuid, session_id, token, ip, hostname, created_at, last_seen) - VALUES (?, ?, ?, ?, ?)`, - clientUUID, sessionID, tokenHash, now, now) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + clientUUID, sessionID, tokenHash, + remoteIP, hostname, now, now) if err != nil { return 0, 0, "", fmt.Errorf( "create login client: %w", err, diff --git a/internal/db/auth_test.go b/internal/db/auth_test.go index 5188925..9b7527c 100644 --- a/internal/db/auth_test.go +++ b/internal/db/auth_test.go @@ -13,7 +13,7 @@ func TestRegisterUser(t *testing.T) { ctx := t.Context() sessionID, clientID, token, err := - database.RegisterUser(ctx, "reguser", "password123") + database.RegisterUser(ctx, "reguser", "password123", "", "", "") if err != nil { t.Fatal(err) } @@ -38,6 +38,69 @@ func TestRegisterUser(t *testing.T) { } } +func TestRegisterUserWithUserHost(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + sessionID, _, _, err := database.RegisterUser( + ctx, "reguhost", "password123", + "myident", "example.org", "", + ) + if err != nil { + t.Fatal(err) + } + + info, err := database.GetSessionHostInfo( + ctx, sessionID, + ) + if err != nil { + t.Fatal(err) + } + + if info.Username != "myident" { + t.Fatalf( + "expected myident, got %s", info.Username, + ) + } + + if info.Hostname != "example.org" { + t.Fatalf( + "expected example.org, got %s", + info.Hostname, + ) + } +} + +func TestRegisterUserDefaultUsername(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + sessionID, _, _, err := database.RegisterUser( + ctx, "regdefault", "password123", "", "", "", + ) + if err != nil { + t.Fatal(err) + } + + info, err := database.GetSessionHostInfo( + ctx, sessionID, + ) + if err != nil { + t.Fatal(err) + } + + if info.Username != "regdefault" { + t.Fatalf( + "expected regdefault, got %s", + info.Username, + ) + } +} + func TestRegisterUserDuplicateNick(t *testing.T) { t.Parallel() @@ -45,7 +108,7 @@ func TestRegisterUserDuplicateNick(t *testing.T) { ctx := t.Context() regSID, regCID, regToken, err := - database.RegisterUser(ctx, "dupnick", "password123") + database.RegisterUser(ctx, "dupnick", "password123", "", "", "") if err != nil { t.Fatal(err) } @@ -55,7 +118,7 @@ func TestRegisterUserDuplicateNick(t *testing.T) { _ = regToken dupSID, dupCID, dupToken, dupErr := - database.RegisterUser(ctx, "dupnick", "other12345") + database.RegisterUser(ctx, "dupnick", "other12345", "", "", "") if dupErr == nil { t.Fatal("expected error for duplicate nick") } @@ -72,7 +135,7 @@ func TestLoginUser(t *testing.T) { ctx := t.Context() regSID, regCID, regToken, err := - database.RegisterUser(ctx, "loginuser", "mypassword") + database.RegisterUser(ctx, "loginuser", "mypassword", "", "", "") if err != nil { t.Fatal(err) } @@ -82,7 +145,7 @@ func TestLoginUser(t *testing.T) { _ = regToken sessionID, clientID, token, err := - database.LoginUser(ctx, "loginuser", "mypassword") + database.LoginUser(ctx, "loginuser", "mypassword", "", "") if err != nil { t.Fatal(err) } @@ -103,6 +166,83 @@ func TestLoginUser(t *testing.T) { } } +func TestLoginUserStoresClientIPHostname(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + regSID, regCID, regToken, err := database.RegisterUser( + ctx, "loginipuser", "password123", + "", "", "10.0.0.1", + ) + + _ = regSID + _ = regCID + _ = regToken + if err != nil { + t.Fatal(err) + } + + _, clientID, _, err := database.LoginUser( + ctx, "loginipuser", "password123", + "10.0.0.99", "newhost.example.com", + ) + if err != nil { + t.Fatal(err) + } + + clientInfo, err := database.GetClientHostInfo( + ctx, clientID, + ) + if err != nil { + t.Fatal(err) + } + + if clientInfo.IP != "10.0.0.99" { + t.Fatalf( + "expected client IP 10.0.0.99, got %s", + clientInfo.IP, + ) + } + + if clientInfo.Hostname != "newhost.example.com" { + t.Fatalf( + "expected hostname newhost.example.com, got %s", + clientInfo.Hostname, + ) + } +} + +func TestRegisterUserStoresSessionIP(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + sessionID, _, _, err := database.RegisterUser( + ctx, "regipuser", "password123", + "ident", "host.local", "172.16.0.5", + ) + if err != nil { + t.Fatal(err) + } + + info, err := database.GetSessionHostInfo( + ctx, sessionID, + ) + if err != nil { + t.Fatal(err) + } + + if info.IP != "172.16.0.5" { + t.Fatalf( + "expected session IP 172.16.0.5, got %s", + info.IP, + ) + } +} + func TestLoginUserWrongPassword(t *testing.T) { t.Parallel() @@ -110,7 +250,7 @@ func TestLoginUserWrongPassword(t *testing.T) { ctx := t.Context() regSID, regCID, regToken, err := - database.RegisterUser(ctx, "wrongpw", "correctpass") + database.RegisterUser(ctx, "wrongpw", "correctpass", "", "", "") if err != nil { t.Fatal(err) } @@ -120,7 +260,7 @@ func TestLoginUserWrongPassword(t *testing.T) { _ = regToken loginSID, loginCID, loginToken, loginErr := - database.LoginUser(ctx, "wrongpw", "wrongpass12") + database.LoginUser(ctx, "wrongpw", "wrongpass12", "", "") if loginErr == nil { t.Fatal("expected error for wrong password") } @@ -138,7 +278,7 @@ func TestLoginUserNoPassword(t *testing.T) { // Create anonymous session (no password). anonSID, anonCID, anonToken, err := - database.CreateSession(ctx, "anon") + database.CreateSession(ctx, "anon", "", "", "") if err != nil { t.Fatal(err) } @@ -148,7 +288,7 @@ func TestLoginUserNoPassword(t *testing.T) { _ = anonToken loginSID, loginCID, loginToken, loginErr := - database.LoginUser(ctx, "anon", "anything1") + database.LoginUser(ctx, "anon", "anything1", "", "") if loginErr == nil { t.Fatal( "expected error for login on passwordless account", @@ -167,7 +307,7 @@ func TestLoginUserNonexistent(t *testing.T) { ctx := t.Context() loginSID, loginCID, loginToken, err := - database.LoginUser(ctx, "ghost", "password123") + database.LoginUser(ctx, "ghost", "password123", "", "") if err == nil { t.Fatal("expected error for nonexistent user") } diff --git a/internal/db/queries.go b/internal/db/queries.go index ff13174..839591b 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -74,14 +74,40 @@ type ChannelInfo struct { type MemberInfo struct { ID int64 `json:"id"` Nick string `json:"nick"` + Username string `json:"username"` + Hostname string `json:"hostname"` LastSeen time.Time `json:"lastSeen"` } +// Hostmask returns the IRC hostmask in +// nick!user@host format. +func (m *MemberInfo) Hostmask() string { + return FormatHostmask(m.Nick, m.Username, m.Hostname) +} + +// FormatHostmask formats a nick, username, and hostname +// into a standard IRC hostmask string (nick!user@host). +func FormatHostmask(nick, username, hostname string) string { + if username == "" { + username = nick + } + + if hostname == "" { + hostname = "*" + } + + return nick + "!" + username + "@" + hostname +} + // CreateSession registers a new session and its first client. func (database *Database) CreateSession( ctx context.Context, - nick string, + nick, username, hostname, remoteIP string, ) (int64, int64, string, error) { + if username == "" { + username = nick + } + sessionUUID := uuid.New().String() clientUUID := uuid.New().String() @@ -101,9 +127,11 @@ func (database *Database) CreateSession( res, err := transaction.ExecContext(ctx, `INSERT INTO sessions - (uuid, nick, created_at, last_seen) - VALUES (?, ?, ?, ?)`, - sessionUUID, nick, now, now) + (uuid, nick, username, hostname, ip, + created_at, last_seen) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + sessionUUID, nick, username, hostname, + remoteIP, now, now) if err != nil { _ = transaction.Rollback() @@ -118,10 +146,11 @@ func (database *Database) CreateSession( clientRes, err := transaction.ExecContext(ctx, `INSERT INTO clients - (uuid, session_id, token, + (uuid, session_id, token, ip, hostname, created_at, last_seen) - VALUES (?, ?, ?, ?, ?)`, - clientUUID, sessionID, tokenHash, now, now) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + clientUUID, sessionID, tokenHash, + remoteIP, hostname, now, now) if err != nil { _ = transaction.Rollback() @@ -209,6 +238,135 @@ func (database *Database) GetSessionByNick( return sessionID, nil } +// SessionHostInfo holds the username, hostname, and IP +// for a session. +type SessionHostInfo struct { + Username string + Hostname string + IP string +} + +// GetSessionHostInfo returns the username, hostname, +// and IP for a session. +func (database *Database) GetSessionHostInfo( + ctx context.Context, + sessionID int64, +) (*SessionHostInfo, error) { + var info SessionHostInfo + + err := database.conn.QueryRowContext( + ctx, + `SELECT username, hostname, ip + FROM sessions WHERE id = ?`, + sessionID, + ).Scan(&info.Username, &info.Hostname, &info.IP) + if err != nil { + return nil, fmt.Errorf( + "get session host info: %w", err, + ) + } + + return &info, nil +} + +// ClientHostInfo holds the IP and hostname for a client. +type ClientHostInfo struct { + IP string + Hostname string +} + +// GetClientHostInfo returns the IP and hostname for a +// client. +func (database *Database) GetClientHostInfo( + ctx context.Context, + clientID int64, +) (*ClientHostInfo, error) { + var info ClientHostInfo + + err := database.conn.QueryRowContext( + ctx, + `SELECT ip, hostname + FROM clients WHERE id = ?`, + clientID, + ).Scan(&info.IP, &info.Hostname) + if err != nil { + return nil, fmt.Errorf( + "get client host info: %w", err, + ) + } + + 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, @@ -388,7 +546,8 @@ func (database *Database) ChannelMembers( channelID int64, ) ([]MemberInfo, error) { rows, err := database.conn.QueryContext(ctx, - `SELECT s.id, s.nick, s.last_seen + `SELECT s.id, s.nick, s.username, + s.hostname, s.last_seen FROM sessions s INNER JOIN channel_members cm ON cm.session_id = s.id @@ -408,7 +567,9 @@ func (database *Database) ChannelMembers( var member MemberInfo err = rows.Scan( - &member.ID, &member.Nick, &member.LastSeen, + &member.ID, &member.Nick, + &member.Username, &member.Hostname, + &member.LastSeen, ) if err != nil { return nil, fmt.Errorf( @@ -859,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 15814a2..e270bdb 100644 --- a/internal/db/queries_test.go +++ b/internal/db/queries_test.go @@ -34,7 +34,7 @@ func TestCreateSession(t *testing.T) { ctx := t.Context() sessionID, _, token, err := database.CreateSession( - ctx, "alice", + ctx, "alice", "", "", "", ) if err != nil { t.Fatal(err) @@ -45,7 +45,7 @@ func TestCreateSession(t *testing.T) { } _, _, dupToken, dupErr := database.CreateSession( - ctx, "alice", + ctx, "alice", "", "", "", ) if dupErr == nil { t.Fatal("expected error for duplicate nick") @@ -54,13 +54,249 @@ func TestCreateSession(t *testing.T) { _ = dupToken } +// assertSessionHostInfo creates a session and verifies +// the stored username and hostname match expectations. +func assertSessionHostInfo( + t *testing.T, + database *db.Database, + nick, inputUser, inputHost, + expectUser, expectHost string, +) { + t.Helper() + + sessionID, _, _, err := database.CreateSession( + t.Context(), nick, inputUser, inputHost, "", + ) + if err != nil { + t.Fatal(err) + } + + info, err := database.GetSessionHostInfo( + t.Context(), sessionID, + ) + if err != nil { + t.Fatal(err) + } + + if info.Username != expectUser { + t.Fatalf( + "expected username %s, got %s", + expectUser, info.Username, + ) + } + + if info.Hostname != expectHost { + t.Fatalf( + "expected hostname %s, got %s", + expectHost, info.Hostname, + ) + } +} + +func TestCreateSessionWithUserHost(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + + assertSessionHostInfo( + t, database, + "hostuser", "myident", "example.com", + "myident", "example.com", + ) +} + +func TestCreateSessionDefaultUsername(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + + // Empty username defaults to nick. + assertSessionHostInfo( + t, database, + "defaultu", "", "host.local", + "defaultu", "host.local", + ) +} + +func TestCreateSessionStoresIP(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + sessionID, clientID, _, err := database.CreateSession( + ctx, "ipuser", "ident", "host.example.com", + "192.168.1.42", + ) + if err != nil { + t.Fatal(err) + } + + info, err := database.GetSessionHostInfo( + ctx, sessionID, + ) + if err != nil { + t.Fatal(err) + } + + if info.IP != "192.168.1.42" { + t.Fatalf( + "expected session IP 192.168.1.42, got %s", + info.IP, + ) + } + + clientInfo, err := database.GetClientHostInfo( + ctx, clientID, + ) + if err != nil { + t.Fatal(err) + } + + if clientInfo.IP != "192.168.1.42" { + t.Fatalf( + "expected client IP 192.168.1.42, got %s", + clientInfo.IP, + ) + } + + if clientInfo.Hostname != "host.example.com" { + t.Fatalf( + "expected client hostname host.example.com, got %s", + clientInfo.Hostname, + ) + } +} + +func TestGetClientHostInfoNotFound(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + + _, err := database.GetClientHostInfo( + t.Context(), 99999, + ) + if err == nil { + t.Fatal("expected error for nonexistent client") + } +} + +func TestGetSessionHostInfoNotFound(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + + _, err := database.GetSessionHostInfo( + t.Context(), 99999, + ) + if err == nil { + t.Fatal("expected error for nonexistent session") + } +} + +func TestFormatHostmask(t *testing.T) { + t.Parallel() + + result := db.FormatHostmask( + "nick", "user", "host.com", + ) + if result != "nick!user@host.com" { + t.Fatalf( + "expected nick!user@host.com, got %s", + result, + ) + } +} + +func TestFormatHostmaskDefaults(t *testing.T) { + t.Parallel() + + result := db.FormatHostmask("nick", "", "") + if result != "nick!nick@*" { + t.Fatalf( + "expected nick!nick@*, got %s", + result, + ) + } +} + +func TestMemberInfoHostmask(t *testing.T) { + t.Parallel() + + member := &db.MemberInfo{ //nolint:exhaustruct // test only uses hostmask fields + Nick: "alice", + Username: "aliceident", + Hostname: "alice.example.com", + } + + hostmask := member.Hostmask() + expected := "alice!aliceident@alice.example.com" + + if hostmask != expected { + t.Fatalf( + "expected %s, got %s", expected, hostmask, + ) + } +} + +func TestChannelMembersIncludeUserHost(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + sid, _, _, err := database.CreateSession( + ctx, "memuser", "myuser", "myhost.net", "", + ) + if err != nil { + t.Fatal(err) + } + + chID, err := database.GetOrCreateChannel( + ctx, "#hostchan", + ) + if err != nil { + t.Fatal(err) + } + + err = database.JoinChannel(ctx, chID, sid) + if err != nil { + t.Fatal(err) + } + + members, err := database.ChannelMembers(ctx, chID) + if err != nil { + t.Fatal(err) + } + + if len(members) != 1 { + t.Fatalf( + "expected 1 member, got %d", len(members), + ) + } + + if members[0].Username != "myuser" { + t.Fatalf( + "expected username myuser, got %s", + members[0].Username, + ) + } + + if members[0].Hostname != "myhost.net" { + t.Fatalf( + "expected hostname myhost.net, got %s", + members[0].Hostname, + ) + } +} + func TestGetSessionByToken(t *testing.T) { t.Parallel() database := setupTestDB(t) ctx := t.Context() - _, _, token, err := database.CreateSession(ctx, "bob") + _, _, token, err := database.CreateSession(ctx, "bob", "", "", "") if err != nil { t.Fatal(err) } @@ -93,7 +329,7 @@ func TestGetSessionByNick(t *testing.T) { ctx := t.Context() charlieID, charlieClientID, charlieToken, err := - database.CreateSession(ctx, "charlie") + database.CreateSession(ctx, "charlie", "", "", "") if err != nil { t.Fatal(err) } @@ -150,7 +386,7 @@ func TestJoinAndPart(t *testing.T) { database := setupTestDB(t) ctx := t.Context() - sid, _, _, err := database.CreateSession(ctx, "user1") + sid, _, _, err := database.CreateSession(ctx, "user1", "", "", "") if err != nil { t.Fatal(err) } @@ -199,7 +435,7 @@ func TestDeleteChannelIfEmpty(t *testing.T) { t.Fatal(err) } - sid, _, _, err := database.CreateSession(ctx, "temp") + sid, _, _, err := database.CreateSession(ctx, "temp", "", "", "") if err != nil { t.Fatal(err) } @@ -234,7 +470,7 @@ func createSessionWithChannels( ctx := t.Context() - sid, _, _, err := database.CreateSession(ctx, nick) + sid, _, _, err := database.CreateSession(ctx, nick, "", "", "") if err != nil { t.Fatal(err) } @@ -317,7 +553,7 @@ func TestChangeNick(t *testing.T) { ctx := t.Context() sid, _, token, err := database.CreateSession( - ctx, "old", + ctx, "old", "", "", "", ) if err != nil { t.Fatal(err) @@ -401,7 +637,7 @@ func TestPollMessages(t *testing.T) { ctx := t.Context() sid, _, token, err := database.CreateSession( - ctx, "poller", + ctx, "poller", "", "", "", ) if err != nil { t.Fatal(err) @@ -508,7 +744,7 @@ func TestDeleteSession(t *testing.T) { ctx := t.Context() sid, _, _, err := database.CreateSession( - ctx, "deleteme", + ctx, "deleteme", "", "", "", ) if err != nil { t.Fatal(err) @@ -548,12 +784,12 @@ func TestChannelMembers(t *testing.T) { database := setupTestDB(t) ctx := t.Context() - sid1, _, _, err := database.CreateSession(ctx, "m1") + sid1, _, _, err := database.CreateSession(ctx, "m1", "", "", "") if err != nil { t.Fatal(err) } - sid2, _, _, err := database.CreateSession(ctx, "m2") + sid2, _, _, err := database.CreateSession(ctx, "m2", "", "", "") if err != nil { t.Fatal(err) } @@ -611,7 +847,7 @@ func TestEnqueueToClient(t *testing.T) { ctx := t.Context() _, _, token, err := database.CreateSession( - ctx, "enqclient", + ctx, "enqclient", "", "", "", ) if err != nil { t.Fatal(err) @@ -651,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 4ea5e28..f58ae29 100644 --- a/internal/db/schema/001_initial.sql +++ b/internal/db/schema/001_initial.sql @@ -6,6 +6,10 @@ CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, nick TEXT NOT NULL UNIQUE, + 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 '', @@ -20,6 +24,8 @@ CREATE TABLE IF NOT EXISTS clients ( uuid TEXT NOT NULL UNIQUE, session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, token TEXT NOT NULL UNIQUE, + ip TEXT NOT NULL DEFAULT '', + hostname TEXT NOT NULL DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_seen DATETIME DEFAULT CURRENT_TIMESTAMP ); diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 7a06e49..9859257 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -2,8 +2,10 @@ package handlers import ( "context" + "crypto/subtle" "encoding/json" "fmt" + "net" "net/http" "regexp" "strconv" @@ -23,6 +25,12 @@ var validChannelRe = regexp.MustCompile( `^#[a-zA-Z0-9_\-]{1,63}$`, ) +var validUsernameRe = regexp.MustCompile( + `^[a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{1,32}$`, +) + +const dnsLookupTimeout = 3 * time.Second + const ( maxLongPollTimeout = 30 pollMessageLimit = 100 @@ -39,6 +47,55 @@ func (hdlr *Handlers) maxBodySize() int64 { return defaultMaxBodySize } +// clientIP extracts the connecting client's IP address +// from the request, checking X-Forwarded-For and +// X-Real-IP headers before falling back to RemoteAddr. +func clientIP(request *http.Request) string { + if forwarded := request.Header.Get("X-Forwarded-For"); forwarded != "" { + // X-Forwarded-For can contain a comma-separated list; + // the first entry is the original client. + parts := strings.SplitN(forwarded, ",", 2) //nolint:mnd + ip := strings.TrimSpace(parts[0]) + + if ip != "" { + return ip + } + } + + if realIP := request.Header.Get("X-Real-IP"); realIP != "" { + return strings.TrimSpace(realIP) + } + + host, _, err := net.SplitHostPort(request.RemoteAddr) + if err != nil { + return request.RemoteAddr + } + + return host +} + +// resolveHostname performs a reverse DNS lookup on the +// given IP address. Returns the first PTR record with the +// trailing dot stripped, or the raw IP if lookup fails. +func resolveHostname( + reqCtx context.Context, + addr string, +) string { + resolver := &net.Resolver{} //nolint:exhaustruct // using default resolver + + ctx, cancel := context.WithTimeout( + reqCtx, dnsLookupTimeout, + ) + defer cancel() + + names, err := resolver.LookupAddr(ctx, addr) + if err != nil || len(names) == 0 { + return addr + } + + return strings.TrimSuffix(names[0], ".") +} + // authSession extracts the session from the client token. func (hdlr *Handlers) authSession( request *http.Request, @@ -146,6 +203,7 @@ func (hdlr *Handlers) handleCreateSession( ) { type createRequest struct { Nick string `json:"nick"` + Username string `json:"username,omitempty"` Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle } @@ -162,30 +220,10 @@ func (hdlr *Handlers) handleCreateSession( return } - // Validate hashcash proof-of-work if configured. - if hdlr.params.Config.HashcashBits > 0 { - if payload.Hashcash == "" { - hdlr.respondError( - writer, request, - "hashcash proof-of-work required", - http.StatusPaymentRequired, - ) - - return - } - - err = hdlr.hashcashVal.Validate( - payload.Hashcash, hdlr.params.Config.HashcashBits, - ) - if err != nil { - hdlr.respondError( - writer, request, - "invalid hashcash stamp: "+err.Error(), - http.StatusPaymentRequired, - ) - - return - } + if !hdlr.validateHashcash( + writer, request, payload.Hashcash, + ) { + return } payload.Nick = strings.TrimSpace(payload.Nick) @@ -200,9 +238,40 @@ func (hdlr *Handlers) handleCreateSession( return } + username := resolveUsername( + payload.Username, payload.Nick, + ) + + if !validUsernameRe.MatchString(username) { + hdlr.respondError( + writer, request, + "invalid username format", + http.StatusBadRequest, + ) + + return + } + + hdlr.executeCreateSession( + writer, request, payload.Nick, username, + ) +} + +func (hdlr *Handlers) executeCreateSession( + writer http.ResponseWriter, + request *http.Request, + nick, username string, +) { + remoteIP := clientIP(request) + + hostname := resolveHostname( + request.Context(), remoteIP, + ) + sessionID, clientID, token, err := hdlr.params.Database.CreateSession( - request.Context(), payload.Nick, + request.Context(), + nick, username, hostname, remoteIP, ) if err != nil { hdlr.handleCreateSessionError( @@ -215,15 +284,64 @@ func (hdlr *Handlers) handleCreateSession( hdlr.stats.IncrSessions() hdlr.stats.IncrConnections() - hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick) + hdlr.deliverMOTD(request, clientID, sessionID, nick) hdlr.respondJSON(writer, request, map[string]any{ "id": sessionID, - "nick": payload.Nick, + "nick": nick, "token": token, }, http.StatusCreated) } +// validateHashcash validates a hashcash stamp if required. +// Returns false if validation failed and a response was +// already sent. +func (hdlr *Handlers) validateHashcash( + writer http.ResponseWriter, + request *http.Request, + stamp string, +) bool { + if hdlr.params.Config.HashcashBits == 0 { + return true + } + + if stamp == "" { + hdlr.respondError( + writer, request, + "hashcash proof-of-work required", + http.StatusPaymentRequired, + ) + + return false + } + + err := hdlr.hashcashVal.Validate( + stamp, hdlr.params.Config.HashcashBits, + ) + if err != nil { + hdlr.respondError( + writer, request, + "invalid hashcash stamp: "+err.Error(), + http.StatusPaymentRequired, + ) + + return false + } + + return true +} + +// resolveUsername returns the trimmed username, defaulting +// to the nick if empty. +func resolveUsername(username, nick string) string { + username = strings.TrimSpace(username) + if username == "" { + return nick + } + + return username +} + func (hdlr *Handlers) handleCreateSessionError( writer http.ResponseWriter, request *http.Request, @@ -343,9 +461,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", ) @@ -875,6 +1003,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, @@ -1357,16 +1490,16 @@ func (hdlr *Handlers) deliverNamesNumerics( ) if memErr == nil && len(members) > 0 { - nicks := make([]string, 0, len(members)) + entries := make([]string, 0, len(members)) for _, mem := range members { - nicks = append(nicks, mem.Nick) + entries = append(entries, mem.Hostmask()) } hdlr.enqueueNumeric( ctx, clientID, irc.RplNamReply, nick, []string{"=", channel}, - strings.Join(nicks, " "), + strings.Join(entries, " "), ) } @@ -1965,16 +2098,16 @@ func (hdlr *Handlers) handleNames( ctx, chID, ) if memErr == nil && len(members) > 0 { - nicks := make([]string, 0, len(members)) + entries := make([]string, 0, len(members)) for _, mem := range members { - nicks = append(nicks, mem.Nick) + entries = append(entries, mem.Hostmask()) } hdlr.enqueueNumeric( ctx, clientID, irc.RplNamReply, nick, []string{"=", channel}, - strings.Join(nicks, " "), + strings.Join(entries, " "), ) } @@ -2081,55 +2214,42 @@ 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 } - // 311 RPL_WHOISUSER - hdlr.enqueueNumeric( - ctx, clientID, irc.RplWhoisUser, nick, - []string{queryNick, queryNick, srvName, "*"}, - queryNick, + hdlr.deliverWhoisUser( + ctx, clientID, nick, queryNick, targetSID, ) - // 312 RPL_WHOISSERVER - hdlr.enqueueNumeric( - ctx, clientID, irc.RplWhoisServer, nick, - []string{queryNick, srvName}, - "neoirc server", + // 313 RPL_WHOISOPERATOR — show if target is oper. + hdlr.deliverWhoisOperator( + ctx, clientID, nick, queryNick, targetSID, ) - // 317 RPL_WHOISIDLE hdlr.deliverWhoisIdle( ctx, clientID, nick, queryNick, targetSID, ) - // 319 RPL_WHOISCHANNELS hdlr.deliverWhoisChannels( ctx, clientID, nick, queryNick, targetSID, ) - // 318 RPL_ENDOFWHOIS + // 338 RPL_WHOISACTUALLY — oper-only. + hdlr.deliverWhoisActually( + ctx, clientID, nick, queryNick, + sessionID, targetSID, + ) + hdlr.enqueueNumeric( ctx, clientID, irc.RplEndOfWhois, nick, []string{queryNick}, @@ -2142,6 +2262,90 @@ func (hdlr *Handlers) executeWhois( 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 + + hostInfo, hostErr := hdlr.params.Database. + GetSessionHostInfo(ctx, targetSID) + if hostErr == nil && hostInfo != nil { + if hostInfo.Username != "" { + username = hostInfo.Username + } + + if hostInfo.Hostname != "" { + hostname = hostInfo.Hostname + } + } + + hdlr.enqueueNumeric( + ctx, clientID, irc.RplWhoisUser, nick, + []string{queryNick, username, hostname, "*"}, + queryNick, + ) + + hdlr.enqueueNumeric( + ctx, clientID, irc.RplWhoisServer, nick, + []string{queryNick, srvName}, + "neoirc server", + ) +} + +// 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 + } + + hdlr.enqueueNumeric( + ctx, clientID, irc.RplWhoisOperator, nick, + []string{queryNick}, + "is an IRC operator", + ) +} + func (hdlr *Handlers) deliverWhoisChannels( ctx context.Context, clientID int64, @@ -2167,6 +2371,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, @@ -2215,11 +2457,21 @@ func (hdlr *Handlers) handleWho( ) if memErr == nil { for _, mem := range members { + username := mem.Username + if username == "" { + username = mem.Nick + } + + hostname := mem.Hostname + if hostname == "" { + hostname = srvName + } + // 352 RPL_WHOREPLY hdlr.enqueueNumeric( ctx, clientID, irc.RplWhoReply, nick, []string{ - channel, mem.Nick, srvName, + channel, username, hostname, srvName, mem.Nick, "H", }, "0 "+mem.Nick, @@ -2542,6 +2794,76 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc { } } +// handleOper handles the OPER command for server operator authentication. +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 == "" || + subtle.ConstantTimeCompare([]byte(operName), []byte(cfgName)) != 1 || + subtle.ConstantTimeCompare([]byte(operPass), []byte(cfgPass)) != 1 { + 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) +} + // handleAway handles the AWAY command. An empty body // clears the away status; a non-empty body sets it. func (hdlr *Handlers) handleAway( diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 68a1d2e..eb5742b 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -2130,6 +2130,249 @@ func TestSessionStillWorks(t *testing.T) { } } +// findNumericWithParams returns the first message matching +// the given numeric code. Returns nil if not found. +func findNumericWithParams( + msgs []map[string]any, + numeric string, +) map[string]any { + want, _ := strconv.Atoi(numeric) + + for _, msg := range msgs { + code, ok := msg["code"].(float64) + if ok && int(code) == want { + return msg + } + } + + return nil +} + +// getNumericParams extracts the params array from a +// numeric message as a string slice. +func getNumericParams( + msg map[string]any, +) []string { + raw, exists := msg["params"] + if !exists || raw == nil { + return nil + } + + arr, isArr := raw.([]any) + if !isArr { + return nil + } + + result := make([]string, 0, len(arr)) + + for _, val := range arr { + str, isString := val.(string) + if isString { + result = append(result, str) + } + } + + return result +} + +func TestWhoisShowsHostInfo(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSessionWithUsername( + "whoisuser", "myident", + ) + + queryToken := tserver.createSession("querier") + + _, lastID := tserver.pollMessages(queryToken, 0) + + tserver.sendCommand(queryToken, map[string]any{ + commandKey: "WHOIS", + toKey: "whoisuser", + }) + + msgs, _ := tserver.pollMessages(queryToken, lastID) + + whoisMsg := findNumericWithParams(msgs, "311") + if whoisMsg == nil { + t.Fatalf( + "expected RPL_WHOISUSER (311), got %v", + msgs, + ) + } + + params := getNumericParams(whoisMsg) + + if len(params) < 2 { + t.Fatalf( + "expected at least 2 params, got %v", + params, + ) + } + + if params[1] != "myident" { + t.Fatalf( + "expected username myident, got %s", + params[1], + ) + } + + _ = token +} + +// createSessionWithUsername creates a session with a +// specific username and returns the token. +func (tserver *testServer) createSessionWithUsername( + nick, username string, +) string { + tserver.t.Helper() + + body, err := json.Marshal(map[string]string{ + "nick": nick, + "username": username, + }) + if err != nil { + tserver.t.Fatalf("marshal session: %v", err) + } + + resp, err := doRequest( + tserver.t, + http.MethodPost, + tserver.url(apiSession), + bytes.NewReader(body), + ) + if err != nil { + tserver.t.Fatalf("create session: %v", err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + respBody, _ := io.ReadAll(resp.Body) + tserver.t.Fatalf( + "create session: status %d: %s", + resp.StatusCode, respBody, + ) + } + + var result struct { + Token string `json:"token"` + } + + _ = json.NewDecoder(resp.Body).Decode(&result) + + return result.Token +} + +func TestWhoShowsHostInfo(t *testing.T) { + tserver := newTestServer(t) + + whoToken := tserver.createSessionWithUsername( + "whouser", "whoident", + ) + + tserver.sendCommand(whoToken, map[string]any{ + commandKey: joinCmd, toKey: "#whotest", + }) + + queryToken := tserver.createSession("whoquerier") + + tserver.sendCommand(queryToken, map[string]any{ + commandKey: joinCmd, toKey: "#whotest", + }) + + _, lastID := tserver.pollMessages(queryToken, 0) + + tserver.sendCommand(queryToken, map[string]any{ + commandKey: "WHO", + toKey: "#whotest", + }) + + msgs, _ := tserver.pollMessages(queryToken, lastID) + + assertWhoReplyUsername(t, msgs, "whouser", "whoident") +} + +func assertWhoReplyUsername( + t *testing.T, + msgs []map[string]any, + targetNick, expectedUsername string, +) { + t.Helper() + + for _, msg := range msgs { + code, isCode := msg["code"].(float64) + if !isCode || int(code) != 352 { + continue + } + + params := getNumericParams(msg) + if len(params) < 5 || params[4] != targetNick { + continue + } + + if params[1] != expectedUsername { + t.Fatalf( + "expected username %s in WHO, got %s", + expectedUsername, params[1], + ) + } + + return + } + + t.Fatalf( + "expected RPL_WHOREPLY (352) for %s, msgs: %v", + targetNick, msgs, + ) +} + +func TestSessionUsernameDefault(t *testing.T) { + tserver := newTestServer(t) + + // Create session without specifying username. + token := tserver.createSession("defaultusr") + + queryToken := tserver.createSession("querier2") + + _, lastID := tserver.pollMessages(queryToken, 0) + + // WHOIS should show the nick as the username. + tserver.sendCommand(queryToken, map[string]any{ + commandKey: "WHOIS", + toKey: "defaultusr", + }) + + msgs, _ := tserver.pollMessages(queryToken, lastID) + + whoisMsg := findNumericWithParams(msgs, "311") + if whoisMsg == nil { + t.Fatalf( + "expected RPL_WHOISUSER (311), got %v", + msgs, + ) + } + + params := getNumericParams(whoisMsg) + + if len(params) < 2 { + t.Fatalf( + "expected at least 2 params, got %v", + params, + ) + } + + // Username defaults to nick. + if params[1] != "defaultusr" { + t.Fatalf( + "expected default username defaultusr, got %s", + params[1], + ) + } + + _ = token +} + func TestNickBroadcastToChannels(t *testing.T) { tserver := newTestServer(t) aliceToken := tserver.createSession("nick_a") @@ -2157,3 +2400,447 @@ func TestNickBroadcastToChannels(t *testing.T) { ) } } + +func TestNamesShowsHostmask(t *testing.T) { + tserver := newTestServer(t) + + queryToken, lastID := setupChannelWithIdentMember( + tserver, "namesmember", "nmident", + "namesquery", "#namestest", + ) + + // Issue an explicit NAMES command. + tserver.sendCommand(queryToken, map[string]any{ + commandKey: "NAMES", + toKey: "#namestest", + }) + + msgs, _ := tserver.pollMessages(queryToken, lastID) + + assertNamesHostmask( + t, msgs, "namesmember", "nmident", + ) +} + +func TestNamesOnJoinShowsHostmask(t *testing.T) { + tserver := newTestServer(t) + + // First user joins to populate the channel. + firstToken := tserver.createSessionWithUsername( + "joinmem", "jmident", + ) + + tserver.sendCommand(firstToken, map[string]any{ + commandKey: joinCmd, toKey: "#joinnamestest", + }) + + // Second user joins; the JOIN triggers + // deliverNamesNumerics which should include + // hostmask data. + joinerToken := tserver.createSession("joiner") + + tserver.sendCommand(joinerToken, map[string]any{ + commandKey: joinCmd, toKey: "#joinnamestest", + }) + + msgs, _ := tserver.pollMessages(joinerToken, 0) + + assertNamesHostmask( + t, msgs, "joinmem", "jmident", + ) +} + +// setupChannelWithIdentMember creates a member session +// with username, joins a channel, then creates a querier +// and joins the same channel. Returns the querier token +// and last message ID. +func setupChannelWithIdentMember( + tserver *testServer, + memberNick, memberUsername, + querierNick, channel string, +) (string, int64) { + tserver.t.Helper() + + memberToken := tserver.createSessionWithUsername( + memberNick, memberUsername, + ) + + tserver.sendCommand(memberToken, map[string]any{ + commandKey: joinCmd, toKey: channel, + }) + + queryToken := tserver.createSession(querierNick) + + tserver.sendCommand(queryToken, map[string]any{ + commandKey: joinCmd, toKey: channel, + }) + + _, lastID := tserver.pollMessages(queryToken, 0) + + return queryToken, lastID +} + +// assertNamesHostmask verifies that a RPL_NAMREPLY (353) +// message contains the expected nick with hostmask format +// (nick!user@host). +func assertNamesHostmask( + t *testing.T, + msgs []map[string]any, + targetNick, expectedUsername string, +) { + t.Helper() + + for _, msg := range msgs { + code, ok := msg["code"].(float64) + if !ok || int(code) != 353 { + continue + } + + raw, exists := msg["body"] + if !exists || raw == nil { + continue + } + + arr, isArr := raw.([]any) + if !isArr || len(arr) == 0 { + continue + } + + bodyStr, isStr := arr[0].(string) + if !isStr { + continue + } + + // Look for the target nick's hostmask entry. + expected := targetNick + "!" + + expectedUsername + "@" + + if !strings.Contains(bodyStr, expected) { + t.Fatalf( + "expected NAMES body to contain %q, "+ + "got %q", + expected, bodyStr, + ) + } + + return + } + + t.Fatalf( + "expected RPL_NAMREPLY (353) with hostmask "+ + "for %s, msgs: %v", + 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/internal/handlers/auth.go b/internal/handlers/auth.go index 293636f..44ee5c3 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -30,6 +30,7 @@ func (hdlr *Handlers) handleRegister( ) { type registerRequest struct { Nick string `json:"nick"` + Username string `json:"username,omitempty"` Password string `json:"password"` } @@ -58,6 +59,20 @@ func (hdlr *Handlers) handleRegister( return } + username := resolveUsername( + payload.Username, payload.Nick, + ) + + if !validUsernameRe.MatchString(username) { + hdlr.respondError( + writer, request, + "invalid username format", + http.StatusBadRequest, + ) + + return + } + if len(payload.Password) < minPasswordLength { hdlr.respondError( writer, request, @@ -68,11 +83,27 @@ func (hdlr *Handlers) handleRegister( return } + hdlr.executeRegister( + writer, request, + payload.Nick, payload.Password, username, + ) +} + +func (hdlr *Handlers) executeRegister( + writer http.ResponseWriter, + request *http.Request, + nick, password, username string, +) { + remoteIP := clientIP(request) + + hostname := resolveHostname( + request.Context(), remoteIP, + ) + sessionID, clientID, token, err := hdlr.params.Database.RegisterUser( request.Context(), - payload.Nick, - payload.Password, + nick, password, username, hostname, remoteIP, ) if err != nil { hdlr.handleRegisterError( @@ -85,11 +116,11 @@ func (hdlr *Handlers) handleRegister( hdlr.stats.IncrSessions() hdlr.stats.IncrConnections() - hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick) + hdlr.deliverMOTD(request, clientID, sessionID, nick) hdlr.respondJSON(writer, request, map[string]any{ "id": sessionID, - "nick": payload.Nick, + "nick": nick, "token": token, }, http.StatusCreated) } @@ -167,11 +198,18 @@ func (hdlr *Handlers) handleLogin( return } + remoteIP := clientIP(request) + + hostname := resolveHostname( + request.Context(), remoteIP, + ) + sessionID, clientID, token, err := hdlr.params.Database.LoginUser( request.Context(), payload.Nick, payload.Password, + remoteIP, hostname, ) if err != nil { hdlr.respondError( 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",