feat: add username/hostname support with IRC hostmask format (#82)
All checks were successful
check / check (push) Successful in 6s

## Summary

Adds username and hostname support to sessions, enabling standard IRC hostmask format (`nick!user@host`) for WHOIS, WHO, and future `+b` ban matching.

closes #81

## Changes

### Schema (`001_initial.sql`)
- Added `username TEXT NOT NULL DEFAULT ''` and `hostname TEXT NOT NULL DEFAULT ''` columns to the `sessions` table

### Database layer (`internal/db/`)
- `CreateSession` now accepts `username` and `hostname` parameters; username defaults to nick if empty
- `RegisterUser` now accepts `username` and `hostname` parameters
- New `SessionHostInfo` type and `GetSessionHostInfo` query to retrieve username/hostname for a session
- `MemberInfo` now includes `Username` and `Hostname` fields
- `ChannelMembers` query updated to return username/hostname
- New `FormatHostmask(nick, username, hostname)` helper that produces `nick!user@host` format
- New `Hostmask()` method on `MemberInfo`

### Handler layer (`internal/handlers/`)
- Session creation (`POST /api/v1/session`) accepts optional `username` field; resolves hostname via reverse DNS of connecting client IP (respects `X-Forwarded-For` and `X-Real-IP` headers)
- Registration (`POST /api/v1/register`) accepts optional `username` field with the same hostname resolution
- Username validation regex: `^[a-zA-Z0-9_\-\[\]\\^{}|` + "\`" + `]{1,32}$`
- WHOIS (`311 RPL_WHOISUSER`) now returns the real username and hostname instead of nick/servername
- WHO (`352 RPL_WHOREPLY`) now returns the real username and hostname instead of nick/servername
- Extracted `validateHashcash` and `resolveUsername` helpers to keep functions under the linter's `funlen` limit
- Extracted `executeRegister` helper for the same reason
- Reverse DNS uses `(*net.Resolver).LookupAddr` with a 3-second timeout context

### Tests
- `TestCreateSessionWithUserHost` — verifies username/hostname are stored and retrievable
- `TestCreateSessionDefaultUsername` — verifies empty username defaults to nick
- `TestGetSessionHostInfoNotFound` — verifies error on nonexistent session
- `TestFormatHostmask` — verifies `nick!user@host` formatting
- `TestFormatHostmaskDefaults` — verifies fallback when username/hostname empty
- `TestMemberInfoHostmask` — verifies `Hostmask()` method on `MemberInfo`
- `TestChannelMembersIncludeUserHost` — verifies `ChannelMembers` returns username/hostname
- `TestRegisterUserWithUserHost` — verifies registration stores username/hostname
- `TestRegisterUserDefaultUsername` — verifies registration defaults username to nick
- `TestWhoisShowsHostInfo` — integration test verifying WHOIS returns the correct username
- `TestWhoShowsHostInfo` — integration test verifying WHO returns the correct username
- `TestSessionUsernameDefault` — integration test verifying default username in WHOIS
- All existing tests updated for new `CreateSession`/`RegisterUser` signatures

### README
- New "Hostmask" section documenting the `nick!user@host` format
- Updated session creation and registration API docs with the new `username` field
- Updated WHOIS/WHO numeric examples to show real username/hostname
- Updated sessions schema table with new columns

## Docker build

`docker build .` passes cleanly (lint, format, tests, build).

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: clawbot <clawbot@eeqj.de>
Reviewed-on: #82
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #82.
This commit is contained in:
2026-03-20 06:53:35 +01:00
committed by Jeffrey Paul
parent bf4d63bc4d
commit db3d23c224
14 changed files with 2009 additions and 137 deletions

142
README.md
View File

@@ -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.
@@ -1107,14 +1176,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 | 132 characters, must be unique on the server |
| `username` | string | No | 132 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
{
@@ -1135,6 +1210,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 |
@@ -1156,14 +1232,18 @@ remains active.
**Request Body:**
```json
{"nick": "alice", "password": "mypassword"}
{"nick": "alice", "username": "alice", "password": "mypassword"}
```
| Field | Type | Required | Constraints |
|------------|--------|----------|-------------|
| `nick` | string | Yes | 132 characters, must be unique on the server |
| `username` | string | No | 132 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
{
@@ -1184,6 +1264,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 |
@@ -1394,6 +1475,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 |
@@ -1422,6 +1504,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):**
@@ -1439,9 +1522,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 |
@@ -1454,6 +1539,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
@@ -1987,28 +2073,34 @@ The database schema is managed via embedded SQL migration files in
**Current tables:**
#### `sessions`
| Column | Type | Description |
|-----------------|----------|-------------|
| `id` | INTEGER | Primary key (auto-increment) |
| `uuid` | TEXT | Unique session UUID |
| `nick` | TEXT | Unique nick |
| `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) |
| `created_at` | DATETIME | Session creation time |
| `last_seen` | DATETIME | Last API request time |
| Column | Type | Description |
|----------------|----------|-------------|
| `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) |
| `created_at` | DATETIME | Session creation time |
| `last_seen` | DATETIME | Last API request time |
Index on `(uuid)`.
#### `clients`
| Column | Type | Description |
|--------------|----------|-------------|
| `id` | INTEGER | Primary key (auto-increment) |
| `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) |
| `created_at` | DATETIME | Client creation time |
| `last_seen` | DATETIME | Last API request time |
| Column | Type | Description |
|-------------|----------|-------------|
| `id` | INTEGER | Primary key (auto-increment) |
| `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 |
Indexes on `(token)` and `(session_id)`.
@@ -2109,6 +2201,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