Compare commits
4 Commits
feature/ch
...
feature/us
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3571c50216 | ||
|
|
16258722c7 | ||
|
|
953771f2aa | ||
|
|
e42c6c1868 |
106
README.md
106
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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -23,6 +24,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 +46,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 +202,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 +219,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 +237,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 +283,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 +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",
|
||||
)
|
||||
|
||||
@@ -875,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,
|
||||
@@ -1357,16 +1489,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 +2097,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 +2213,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 +2261,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 +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,
|
||||
@@ -2215,11 +2456,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,
|
||||
@@ -2544,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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -11,6 +11,7 @@ const (
|
||||
CmdNames = "NAMES"
|
||||
CmdNick = "NICK"
|
||||
CmdNotice = "NOTICE"
|
||||
CmdOper = "OPER"
|
||||
CmdPart = "PART"
|
||||
CmdPing = "PING"
|
||||
CmdPong = "PONG"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user