6 Commits

Author SHA1 Message Date
user
142d0f5919 feat: implement Tier 3 utility IRC commands
All checks were successful
check / check (push) Successful in 59s
Implement all 7 utility IRC commands from issue #87:

User commands:
- USERHOST: quick lookup of user@host for up to 5 nicks (RPL 302)
- VERSION: server version string using globals.Version (RPL 351)
- ADMIN: server admin contact info (RPL 256-259)
- INFO: server software info text (RPL 371/374)
- TIME: server local time in RFC format (RPL 391)

Oper commands:
- KILL: forcibly disconnect a user (requires is_oper), broadcasts
  QUIT to all shared channels, cleans up sessions
- WALLOPS: broadcast message to all users with +w usermode
  (requires is_oper)

Supporting changes:
- Add is_wallops column to sessions table in 001_initial.sql
- Add user mode +w tracking via MODE nick +w/-w
- User mode queries now return actual modes (+o, +w)
- MODE -o allows de-opering yourself; MODE +o rejected
- MODE for other users returns ERR_USERSDONTMATCH (502)
- Extract dispatch helpers to reduce dispatchCommand complexity

Tests cover all commands including error cases, oper checks,
user mode set/unset, KILL broadcast, WALLOPS delivery, and
edge cases (self-kill, nonexistent users, missing params).

closes #87
2026-03-26 21:56:36 -07:00
9a79d92c0d feat: implement Tier 2 channel modes (+b/+i/+s/+k/+l) (#92)
Some checks failed
check / check (push) Failing after 1m31s
## Summary

Implements the second tier of IRC channel features as described in [#86](sneak/chat#86).

## Features

### 1. Ban System (+b)
- `channel_bans` table with mask, set_by, created_at
- Add/remove/list bans via MODE +b/-b
- Wildcard matching (`*!*@*.example.com`, `badnick!*@*`, etc.)
- Ban enforcement on both JOIN and PRIVMSG
- RPL_BANLIST (367) / RPL_ENDOFBANLIST (368) for ban listing

### 2. Invite-Only (+i)
- `is_invite_only` column on channels table
- INVITE command: operators can invite users
- `channel_invites` table tracks pending invites
- Invites consumed on successful JOIN
- ERR_INVITEONLYCHAN (473) for uninvited JOIN attempts

### 3. Secret (+s)
- `is_secret` column on channels table
- Secret channels hidden from LIST for non-members
- Secret channels hidden from WHOIS channel list for non-members

### 4. Channel Key (+k)
- `channel_key` column on channels table
- MODE +k sets key, MODE -k clears it
- Key required on JOIN (`JOIN #channel key`)
- ERR_BADCHANNELKEY (475) for wrong/missing key

### 5. User Limit (+l)
- `user_limit` column on channels table (0 = no limit)
- MODE +l sets limit, MODE -l removes it
- ERR_CHANNELISFULL (471) when limit reached

## ISUPPORT Changes
- CHANMODES updated to `b,k,Hl,imnst`
- RPL_MYINFO modes updated to `ikmnostl`

## Tests

### Database-level tests:
- Wildcard matching (10 patterns)
- Ban CRUD operations
- Session ban checking
- Invite-only flag toggle
- Invite CRUD + clearing
- Secret channel filtering (LIST and WHOIS)
- Channel key set/get/clear
- User limit set/get/clear

### Handler-level tests:
- Ban add/remove/list via MODE
- Ban blocks JOIN
- Ban blocks PRIVMSG
- Invite-only JOIN rejection + INVITE acceptance
- Secret channel hidden from LIST
- Channel key required on JOIN
- User limit enforcement
- Mode string includes new modes
- ISUPPORT updated CHANMODES
- Non-operators cannot set any Tier 2 modes

## Schema Changes
- Added `is_invite_only`, `is_secret`, `channel_key`, `user_limit` to `channels` table
- Added `channel_bans` table
- Added `channel_invites` table
- All changes in `001_initial.sql` (pre-1.0.0 repo)

closes #86

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: sneak/chat#92
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-25 22:38:46 +01:00
e62962d192 fix: use in-memory SQLite for handler tests to fix CI timeout (#93)
All checks were successful
check / check (push) Successful in 6s
## Summary

Fixes the CI build failure caused by the `internal/handlers` test package exceeding the 30-second per-package timeout on the x86_64 CI runner.

## Root Cause

Each of the 104 handler tests was creating a **file-backed SQLite database** in a temp directory with WAL journaling. On slower CI runners (x86_64 ubuntu-latest), the cumulative filesystem I/O overhead for 104 DB create + migrate + teardown cycles pushed the package well past the 30s timeout.

## Fix

1. **In-memory SQLite** — Switch test databases from `file:<tmpdir>/test.db?_journal_mode=WAL&_busy_timeout=5000` to `file:test_<ptr>?mode=memory&cache=shared`. Each test still gets its own isolated database (unique name per `*testing.T` pointer), but without filesystem I/O.

2. **Consolidated test server constructors** — Merged the duplicate `newTestServer()` and `newTestServerWithOper()` setup code into a shared `newTestServerWith()` helper, removing ~50 lines of duplication.

## Results

| Environment | Before | After |
|---|---|---|
| ARM native (no race) | ~4.5s | ~2.0s |
| ARM native (with race) | ~11.5s | ~8.7s |
| Docker ARM (with race+cover) | **~20.4s** | **~10.0s** |

The Docker ARM time is the closest proxy for CI. With the ~2x overhead of x86_64 emulation on CI, the estimated CI time is ~20s — well within the 30s timeout.

## What This Does NOT Change

- No test assertions modified
- No tests skipped or removed
- No linter config changes
- No Makefile changes
- No CI config changes
- All 104 handler tests still run with full isolation

closes sneak/chat#90

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: sneak/chat#93
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-25 20:11:34 +01:00
4b445e6383 feat: implement Tier 1 channel modes (+o/+v/+m/+t), KICK, NOTICE (#88)
Some checks failed
check / check (push) Failing after 1m37s
## Summary

Implement the core IRC channel functionality that users will immediately notice is missing. This is the foundation for all other mode enforcement.

closes #85

## Changes

### 1. Channel Member Flags Schema
- Added `is_operator INTEGER NOT NULL DEFAULT 0` and `is_voiced INTEGER NOT NULL DEFAULT 0` columns to `channel_members` table
- Proper boolean columns per sneak's instruction (not text string modes)

### 2. MODE +o/+v/-o/-v (User Channel Modes)
- `MODE #channel +o nick` / `-o` / `+v` / `-v` with permission checks
- Only existing `+o` users can grant/revoke modes
- NAMES reply shows `@nick` for operators, `+nick` for voiced users
- ISUPPORT advertises `PREFIX=(ov)@+`

### 3. MODE +m (Moderated)
- Added `is_moderated INTEGER NOT NULL DEFAULT 0` to `channels` table
- When +m is active, only +o and +v users can send PRIVMSG/NOTICE
- Others receive `ERR_CANNOTSENDTOCHAN` (404)

### 4. MODE +t (Topic Lock)
- Added `is_topic_locked INTEGER NOT NULL DEFAULT 1` to `channels` table
- Default ON for new channels (standard IRC behavior)
- When +t is active, only +o users can change the topic
- Others receive `ERR_CHANOPRIVSNEEDED` (482)

### 5. KICK Command
- `KICK #channel nick [:reason]` — operator-only
- Broadcasts KICK to all channel members including kicked user
- Removes kicked user from channel
- Proper error handling (482, 441, 403)

### 6. NOTICE Differentiation
- NOTICE does NOT trigger RPL_AWAY auto-replies
- NOTICE skips hashcash validation on +H channels
- Follows RFC 2812 (no auto-replies)

### Additional Improvements
- Channel creator auto-gets +o on first JOIN
- ISUPPORT: `PREFIX=(ov)@+`, `CHANMODES=,,H,mnst`
- MODE query shows accurate mode string (+nt, +m, +H)
- Fixed pre-existing unparam lint issue in fanOutSilent

## Testing

22 new tests covering:
- Operator auto-grant on channel creation
- Second joiner does NOT get +o
- MODE +o/+v/-o/-v with permission checks
- Non-operator cannot grant modes (482)
- +m enforcement (blocks non-voiced, allows op and voiced)
- +t enforcement (blocks non-op topic change, allows op)
- +t disable allows anyone to change topic
- KICK by operator (success + removal verification)
- KICK by non-operator (482)
- KICK target not in channel (441)
- KICK broadcast to all members
- KICK default reason
- NOTICE no AWAY reply
- PRIVMSG DOES trigger AWAY reply
- NOTICE skips hashcash on +H
- +m blocks NOTICE too
- Non-op cannot set +m
- ISUPPORT PREFIX=(ov)@+
- MODE query shows +m

## CI

`docker build .` passes — lint (0 issues), fmt-check, and all tests green.

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: sneak/chat#88
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-25 02:08:28 +01:00
08f57bc105 feat: add per-IP rate limiting to login endpoint (#78)
All checks were successful
check / check (push) Successful in 6s
## Summary

Adds per-IP rate limiting to `POST /api/v1/login` to prevent brute-force password attacks.

closes #35

## What Changed

### New package: `internal/ratelimit/`

A generic per-key token-bucket rate limiter built on `golang.org/x/time/rate`:
- `New(ratePerSec, burst)` creates a limiter with automatic background cleanup of stale entries
- `Allow(key)` checks if a request from the given key should be permitted
- `Stop()` terminates the background sweep goroutine
- Stale entries (unused for 15 minutes) are pruned every 10 minutes

### Login handler integration

The login handler (`internal/handlers/auth.go`) now:
1. Extracts the client IP from `X-Forwarded-For`, `X-Real-IP`, or `RemoteAddr`
2. Checks the per-IP rate limiter before processing the login
3. Returns **429 Too Many Requests** with a `Retry-After: 1` header when the limit is exceeded

### Configuration

Two new environment variables (via Viper):

| Variable | Default | Description |
|---|---|---|
| `LOGIN_RATE_LIMIT` | `1` | Allowed login attempts per second per IP |
| `LOGIN_RATE_BURST` | `5` | Maximum burst of login attempts per IP |

### Scope

Per [sneak's instruction](sneak/chat#35), only the login endpoint is rate-limited. Session creation and registration use hashcash proof-of-work instead.

## Tests

- 6 unit tests for the `ratelimit` package (constructor, burst, burst exceeded, key isolation, key tracking, stop)
- 2 integration tests in `api_test.go`:
  - `TestLoginRateLimitExceeded`: exhausts burst with rapid requests, verifies 429 response and `Retry-After` header
  - `TestLoginRateLimitAllowsNormalUse`: verifies normal login still works

## README

- Added "Login Rate Limiting" subsection under "Rate Limiting & Abuse Prevention"
- Added `LOGIN_RATE_LIMIT` and `LOGIN_RATE_BURST` to the Configuration table

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: sneak/chat#78
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-22 00:39:38 +01:00
5f3c0633f6 refactor: replace Bearer token auth with HttpOnly cookies (#84)
All checks were successful
check / check (push) Successful in 2m34s
## Summary

Major auth refactor replacing Bearer token authentication with HttpOnly cookie-based auth, removing the registration endpoint, and adding the PASS IRC command for password management.

## Changes

### Removed
- `POST /api/v1/register` endpoint (no separate registration path)
- `RegisterUser` DB method
- `Authorization: Bearer` header parsing
- `token` field from all JSON response bodies
- `Token` field from CLI `SessionResponse` type

### Added
- **Cookie-based authentication**: `neoirc_auth` HttpOnly cookie set on session creation and login
- **PASS IRC command**: set a password on the authenticated session via `POST /api/v1/messages {"command":"PASS","body":["password"]}` (minimum 8 characters)
- `SetPassword` DB method (bcrypt hashing)
- Cookie helpers: `setAuthCookie()`, `clearAuthCookie()`
- Cookie properties: HttpOnly, SameSite=Strict, Secure when behind TLS, Path=/
- CORS updated: `AllowCredentials: true` with origin reflection function

### Auth Flow
1. `POST /api/v1/session {"nick":"alice"}` → sets `neoirc_auth` cookie, returns `{"id":1,"nick":"alice"}`
2. (Optional) `POST /api/v1/messages {"command":"PASS","body":["s3cret"]}` → sets password for multi-client
3. Another client: `POST /api/v1/login {"nick":"alice","password":"s3cret"}` → sets `neoirc_auth` cookie
4. Logout and QUIT clear the cookie

### Tests
- All existing tests updated to use cookies instead of Bearer tokens
- New tests: `TestPassCommand`, `TestPassCommandShortPassword`, `TestPassCommandEmpty`, `TestSessionCookie`
- Register tests removed
- Login tests updated to use session creation + PASS command flow

### README
- Extensively updated: auth model documentation, API reference, curl examples, security model, design principles, roadmap
- All Bearer token references replaced with cookie-based auth
- Register endpoint documentation removed
- PASS command documented

### CI
- `docker build .` passes (format check, lint, all tests, build)

closes sneak/chat#83

Co-authored-by: clawbot <clawbot@eeqj.de>
Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: sneak/chat#84
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-20 23:54:23 +01:00
16 changed files with 6926 additions and 235 deletions

166
README.md
View File

@@ -212,13 +212,13 @@ Each session has an IRC-style hostmask composed of three parts:
- **nick** — the user's current nick (changes with `NICK` command) - **nick** — the user's current nick (changes with `NICK` command)
- **username** — an ident-like identifier set at session creation (optional - **username** — an ident-like identifier set at session creation (optional
`username` field in the session/register request; defaults to the nick) `username` field in the session request; defaults to the nick)
- **hostname** — automatically resolved via reverse DNS of the connecting - **hostname** — automatically resolved via reverse DNS of the connecting
client's IP address at session creation time client's IP address at session creation time
- **ip** — the real IP address of the session creator, extracted from - **ip** — the real IP address of the session creator, extracted from
`X-Forwarded-For`, `X-Real-IP`, or `RemoteAddr` `X-Forwarded-For`, `X-Real-IP`, or `RemoteAddr`
Each **client connection** (created at session creation, registration, or login) Each **client connection** (created at session creation or login)
also stores its own **ip** and **hostname**, allowing the server to track the also stores its own **ip** and **hostname**, allowing the server to track the
network origin of each individual client independently from the session. network origin of each individual client independently from the session.
Client-level IP and hostname are **not displayed to regular users**. They are Client-level IP and hostname are **not displayed to regular users**. They are
@@ -824,8 +824,9 @@ Set or change a channel's topic.
- Updates the channel's topic in the database. - Updates the channel's topic in the database.
- The TOPIC event is broadcast to all channel members. - The TOPIC event is broadcast to all channel members.
- If the channel doesn't exist, the server returns an error. - If the channel doesn't exist, the server returns an error.
- If the channel has mode `+t` (topic lock), only operators can change the - If the channel has mode `+t` (topic lock, default: ON for new channels),
topic (not yet enforced). only operators (`+o`) can change the topic. Non-operators receive
`ERR_CHANOPRIVSNEEDED` (482).
**Response:** `200 OK` **Response:** `200 OK`
```json ```json
@@ -1020,16 +1021,23 @@ WHOIS responses (e.g., target user's current client IP and hostname).
**IRC reference:** RFC 1459 §4.1.5 **IRC reference:** RFC 1459 §4.1.5
#### KICK — Kick User (Planned) #### KICK — Kick User
Remove a user from a channel. Remove a user from a channel. Only channel operators (`+o`) can use this
command. The kicked user and all channel members receive the KICK message.
**C2S:** **C2S:**
```json ```json
{"command": "KICK", "to": "#general", "params": ["bob"], "body": ["misbehaving"]} {"command": "KICK", "to": "#general", "body": ["bob", "misbehaving"]}
``` ```
**Status:** Not yet implemented. The first element of `body` is the target nick, the second (optional) is the
reason. If no reason is provided, the kicker's nick is used as the default.
**Errors:**
- `482` (ERR_CHANOPRIVSNEEDED) — kicker is not a channel operator
- `441` (ERR_USERNOTINCHANNEL) — target is not in the channel
- `403` (ERR_NOSUCHCHANNEL) — channel does not exist
**IRC reference:** RFC 1459 §4.2.8 **IRC reference:** RFC 1459 §4.2.8
@@ -1071,8 +1079,8 @@ the server to the client (never C2S) and use 3-digit string codes in the
| `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` | | `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` |
| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is neoirc, running version 0.1"]}` | | `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is neoirc, running version 0.1"]}` |
| `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` | | `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` |
| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc","0.1","","imnst"]}` | | `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc","0.1","","ikmnostl"]}` |
| `005` | RPL_ISUPPORT | After session creation | `{"command":"005","to":"alice","params":["CHANTYPES=#","NICKLEN=32","NETWORK=neoirc"],"body":["are supported by this server"]}` | | `005` | RPL_ISUPPORT | After session creation | `{"command":"005","to":"alice","params":["CHANTYPES=#","NICKLEN=32","PREFIX=(ov)@+","CHANMODES=b,k,Hl,imnst","NETWORK=neoirc"],"body":["are supported by this server"]}` |
| `221` | RPL_UMODEIS | In response to user MODE query | `{"command":"221","to":"alice","body":["+"]}` | | `221` | RPL_UMODEIS | In response to user MODE query | `{"command":"221","to":"alice","body":["+"]}` |
| `251` | RPL_LUSERCLIENT | On connect or LUSERS command | `{"command":"251","to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` | | `251` | RPL_LUSERCLIENT | On connect or LUSERS command | `{"command":"251","to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` |
| `252` | RPL_LUSEROP | On connect or LUSERS command | `{"command":"252","to":"alice","params":["0"],"body":["operator(s) online"]}` | | `252` | RPL_LUSEROP | On connect or LUSERS command | `{"command":"252","to":"alice","params":["0"],"body":["operator(s) online"]}` |
@@ -1118,25 +1126,73 @@ carries IRC-style parameters (e.g., channel name, target nick).
Inspired by IRC, simplified: Inspired by IRC, simplified:
| Mode | Name | Meaning | | Mode | Name | Meaning | Status |
|------|----------------|---------| |------|----------------|---------|--------|
| `+i` | Invite-only | Only invited users can join | | `+b` | Ban | Prevents matching hostmasks from joining or sending (parameter: `nick!user@host` mask with wildcards) | **Enforced** |
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | | `+i` | Invite-only | Only invited users can join; use `INVITE nick #channel` to invite | **Enforced** |
| `+s` | Secret | Channel hidden from LIST response | | `+k` | Channel key | Requires a password to join (parameter: key string) | **Enforced** |
| `+t` | Topic lock | Only operators can change the topic | | `+l` | User limit | Maximum number of members allowed in the channel (parameter: integer) | **Enforced** |
| `+n` | No external | Only channel members can send messages to the channel | | `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** |
| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | | `+n` | No external | Only channel members can send messages to the channel | **Enforced** |
| `+s` | Secret | Channel hidden from LIST and WHOIS for non-members | **Enforced** |
| `+t` | Topic lock | Only operators can change the topic (default: ON) | **Enforced** |
| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | **Enforced** |
**User channel modes (set per-user per-channel):** **User channel modes (set per-user per-channel):**
| Mode | Meaning | Display prefix | | Mode | Meaning | Display prefix | Status |
|------|---------|----------------| |------|---------|----------------|--------|
| `+o` | Operator | `@` in NAMES reply | | `+o` | Operator | `@` in NAMES reply | **Enforced** |
| `+v` | Voice | `+` in NAMES reply | | `+v` | Voice | `+` in NAMES reply | **Enforced** |
**Status:** Channel modes are defined but not yet enforced. The `modes` column **Channel creator auto-op:** The first user to JOIN a channel (creating it)
exists in the channels table but the server does not check modes on actions. automatically receives `+o` operator status.
Exception: `+H` (hashcash) is fully enforced — see below.
**Ban system (+b):** Operators can ban users by hostmask pattern with wildcard
matching (`*` and `?`). `MODE #channel +b` with no argument lists current bans.
Bans prevent both joining and sending messages.
```
MODE #channel +b *!*@*.example.com — ban all users from example.com
MODE #channel -b *!*@*.example.com — remove the ban
MODE #channel +b — list all bans (RPL_BANLIST 367/368)
```
**Invite-only (+i):** When set, users must be invited by an operator before
joining. The `INVITE` command records an invite that is consumed on JOIN.
```
MODE #channel +i — set invite-only
INVITE nick #channel — invite a user (operator only on +i channels)
```
**Channel key (+k):** Requires a password to join the channel.
```
MODE #channel +k secretpass — set a channel key
MODE #channel -k * — remove the key
JOIN #channel secretpass — join with key
```
**User limit (+l):** Caps the number of members in the channel.
```
MODE #channel +l 50 — set limit to 50 members
MODE #channel -l — remove the limit
```
**Secret (+s):** Hides the channel from `LIST` for non-members and from
`WHOIS` channel lists when the querier is not in the same channel.
**KICK command:** Channel operators can remove users with `KICK #channel nick
[:reason]`. The kicked user and all channel members receive the KICK message.
**NOTICE:** Follows RFC 2812 — NOTICE never triggers auto-replies (including
RPL_AWAY), and skips hashcash validation on +H channels (servers and services
use NOTICE).
**ISUPPORT:** The server advertises `PREFIX=(ov)@+` and
`CHANMODES=b,k,Hl,imnst` in RPL_ISUPPORT (005).
### Per-Channel Hashcash (Anti-Spam) ### Per-Channel Hashcash (Anti-Spam)
@@ -2208,6 +2264,8 @@ directory is also loaded automatically via
| `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_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_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. | | `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. |
| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. |
| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. |
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) | | `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
### Example `.env` file ### Example `.env` file
@@ -2468,8 +2526,7 @@ Clients should handle these message commands from the queue:
- **HTTP 401**: Auth cookie expired or invalid. Re-create session or - **HTTP 401**: Auth cookie expired or invalid. Re-create session or
re-login (if a password was set). re-login (if a password was set).
- **HTTP 404**: Channel or user not found. - **HTTP 404**: Channel or user not found.
- **HTTP 409**: Nick already taken (on session creation, registration, or - **HTTP 409**: Nick already taken (on session creation or NICK change).
NICK change).
- **HTTP 400**: Malformed request. Check the `error` field in the response. - **HTTP 400**: Malformed request. Check the `error` field in the response.
- **Network errors**: Back off exponentially (1s, 2s, 4s, ..., max 30s). - **Network errors**: Back off exponentially (1s, 2s, 4s, ..., max 30s).
@@ -2602,6 +2659,49 @@ creating one session pays once and keeps their session.
- **Language-agnostic**: SHA-256 is available in every programming language. - **Language-agnostic**: SHA-256 is available in every programming language.
The proof computation is trivially implementable in any client. The proof computation is trivially implementable in any client.
### Login Rate Limiting
The login endpoint (`POST /api/v1/login`) has per-IP rate limiting to prevent
brute-force password attacks. This uses a token-bucket algorithm
(`golang.org/x/time/rate`) with configurable rate and burst.
| Environment Variable | Default | Description |
|---------------------|---------|-------------|
| `LOGIN_RATE_LIMIT` | `1` | Allowed login attempts per second per IP |
| `LOGIN_RATE_BURST` | `5` | Maximum burst of login attempts per IP |
When the limit is exceeded, the server returns **429 Too Many Requests** with a
`Retry-After: 1` header. Stale per-IP entries are automatically cleaned up
every 10 minutes.
> **⚠️ Security: Reverse Proxy Required for Production Use**
>
> The rate limiter extracts the client IP by checking the `X-Forwarded-For`
> header first, then `X-Real-IP`, and finally falling back to the TCP
> `RemoteAddr`. Both `X-Forwarded-For` and `X-Real-IP` are **client-controlled
> request headers** — any client can set them to arbitrary values.
>
> Without a properly configured reverse proxy in front of this server:
>
> - An attacker can **bypass rate limiting entirely** by rotating
> `X-Forwarded-For` values on each request (each value is treated as a
> distinct IP).
> - An attacker can **deny service to a specific user** by spoofing that user's
> IP in the `X-Forwarded-For` header, exhausting their rate limit bucket.
>
> **Recommendation:** Always deploy behind a reverse proxy (e.g. nginx, Caddy,
> Traefik) that strips or overwrites incoming `X-Forwarded-For` and `X-Real-IP`
> headers with the actual client IP. If running without a reverse proxy, be
> aware that the rate limiting provides no meaningful protection against a
> targeted attack.
**Why rate limits here but not on session creation?** Session creation is
protected by hashcash proof-of-work (stateless, no IP tracking needed). Login
involves bcrypt password verification against a registered account — a
fundamentally different threat model where an attacker targets a specific
account. Per-IP rate limiting is appropriate here because the cost of a wrong
guess is borne by the server (bcrypt), not the client.
--- ---
## Roadmap ## Roadmap
@@ -2633,17 +2733,19 @@ creating one session pays once and keeps their session.
- [x] **Hashcash proof-of-work** for session creation (abuse prevention) - [x] **Hashcash proof-of-work** for session creation (abuse prevention)
- [x] **Client output queue pruning** — delete old client output queue entries per `QUEUE_MAX_AGE` - [x] **Client output queue pruning** — delete old client output queue entries per `QUEUE_MAX_AGE`
- [x] **Message rotation** — prune messages older than `MESSAGE_MAX_AGE` - [x] **Message rotation** — prune messages older than `MESSAGE_MAX_AGE`
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n` - [x] **Channel modes** — enforce `+m` (moderated), `+t` (topic lock), `+n` (no external)
- [ ] **User channel modes** — `+o` (operator), `+v` (voice) - [x] **Channel modes (tier 2)** — enforce `+i` (invite-only), `+s` (secret), `+b` (ban), `+k` (key), `+l` (limit)
- [x] **MODE command** — query channel and user modes (set not yet implemented) - [x] **User channel modes** — `+o` (operator), `+v` (voice) with NAMES prefixes
- [x] **NAMES command** — query channel member list - [x] **KICK command** — operator-only channel kick with broadcast
- [x] **MODE command** — query and set channel/user modes
- [x] **NAMES command** — query channel member list with @/+ prefixes
- [x] **LIST command** — list all channels with member counts - [x] **LIST command** — list all channels with member counts
- [x] **WHOIS command** — query user information and channel membership - [x] **WHOIS command** — query user information and channel membership
- [x] **WHO command** — query channel user list - [x] **WHO command** — query channel user list
- [x] **LUSERS command** — query server statistics - [x] **LUSERS command** — query server statistics
- [x] **Connection registration numerics** — 001-005 sent on session creation - [x] **Connection registration numerics** — 001-005 sent on session creation
- [x] **LUSERS numerics** — 251/252/254/255 sent on connect and via /LUSERS - [x] **LUSERS numerics** — 251/252/254/255 sent on connect and via /LUSERS
- [ ] **KICK command** — remove users from channels - [x] **KICK command** — remove users from channels (operator-only)
- [x] **Numeric replies** — send IRC numeric codes via the message queue - [x] **Numeric replies** — send IRC numeric codes via the message queue
(001-005 welcome, 251-255 LUSERS, 311-319 WHOIS, 322-329 LIST/MODE, (001-005 welcome, 251-255 LUSERS, 311-319 WHOIS, 322-329 LIST/MODE,
331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors) 331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors)

1
go.mod
View File

@@ -16,6 +16,7 @@ require (
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
go.uber.org/fx v1.24.0 go.uber.org/fx v1.24.0
golang.org/x/crypto v0.48.0 golang.org/x/crypto v0.48.0
golang.org/x/time v0.6.0
modernc.org/sqlite v1.45.0 modernc.org/sqlite v1.45.0
) )

2
go.sum
View File

@@ -151,6 +151,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

View File

@@ -48,6 +48,8 @@ type Config struct {
HashcashBits int HashcashBits int
OperName string OperName string
OperPassword string OperPassword string
LoginRateLimit float64
LoginRateBurst int
params *Params params *Params
log *slog.Logger log *slog.Logger
} }
@@ -82,6 +84,8 @@ func New(
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20") viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
viper.SetDefault("NEOIRC_OPER_NAME", "") viper.SetDefault("NEOIRC_OPER_NAME", "")
viper.SetDefault("NEOIRC_OPER_PASSWORD", "") viper.SetDefault("NEOIRC_OPER_PASSWORD", "")
viper.SetDefault("LOGIN_RATE_LIMIT", "1")
viper.SetDefault("LOGIN_RATE_BURST", "5")
err := viper.ReadInConfig() err := viper.ReadInConfig()
if err != nil { if err != nil {
@@ -110,6 +114,8 @@ func New(
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"), HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
OperName: viper.GetString("NEOIRC_OPER_NAME"), OperName: viper.GetString("NEOIRC_OPER_NAME"),
OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"), OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"),
LoginRateLimit: viper.GetFloat64("LOGIN_RATE_LIMIT"),
LoginRateBurst: viper.GetInt("LOGIN_RATE_BURST"),
log: log, log: log,
params: &params, params: &params,
} }

View File

@@ -9,6 +9,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv" "strconv"
"strings"
"time" "time"
"git.eeqj.de/sneak/neoirc/pkg/irc" "git.eeqj.de/sneak/neoirc/pkg/irc"
@@ -76,6 +77,8 @@ type MemberInfo struct {
Nick string `json:"nick"` Nick string `json:"nick"`
Username string `json:"username"` Username string `json:"username"`
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
IsOperator bool `json:"isOperator"`
IsVoiced bool `json:"isVoiced"`
LastSeen time.Time `json:"lastSeen"` LastSeen time.Time `json:"lastSeen"`
} }
@@ -436,6 +439,237 @@ func (database *Database) JoinChannel(
return nil return nil
} }
// JoinChannelAsOperator adds a session to a channel with
// operator status. Used when a user creates a new channel.
func (database *Database) JoinChannelAsOperator(
ctx context.Context,
channelID, sessionID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO channel_members
(channel_id, session_id, is_operator, joined_at)
VALUES (?, ?, 1, ?)`,
channelID, sessionID, time.Now())
if err != nil {
return fmt.Errorf(
"join channel as operator: %w", err,
)
}
return nil
}
// CountChannelMembers returns the number of members in
// a channel.
func (database *Database) CountChannelMembers(
ctx context.Context,
channelID int64,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM channel_members
WHERE channel_id = ?`,
channelID,
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"count channel members: %w", err,
)
}
return count, nil
}
// IsChannelOperator checks if a session has operator
// status in a channel.
func (database *Database) IsChannelOperator(
ctx context.Context,
channelID, sessionID int64,
) (bool, error) {
var isOp int
err := database.conn.QueryRowContext(ctx,
`SELECT is_operator FROM channel_members
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID,
).Scan(&isOp)
if err != nil {
return false, fmt.Errorf(
"check channel operator: %w", err,
)
}
return isOp != 0, nil
}
// IsChannelVoiced checks if a session has voice status
// in a channel.
func (database *Database) IsChannelVoiced(
ctx context.Context,
channelID, sessionID int64,
) (bool, error) {
var isVoiced int
err := database.conn.QueryRowContext(ctx,
`SELECT is_voiced FROM channel_members
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID,
).Scan(&isVoiced)
if err != nil {
return false, fmt.Errorf(
"check channel voiced: %w", err,
)
}
return isVoiced != 0, nil
}
// SetChannelMemberOperator sets or clears operator status
// for a session in a channel.
func (database *Database) SetChannelMemberOperator(
ctx context.Context,
channelID, sessionID int64,
isOp bool,
) error {
val := 0
if isOp {
val = 1
}
_, err := database.conn.ExecContext(ctx,
`UPDATE channel_members
SET is_operator = ?
WHERE channel_id = ? AND session_id = ?`,
val, channelID, sessionID)
if err != nil {
return fmt.Errorf(
"set channel member operator: %w", err,
)
}
return nil
}
// SetChannelMemberVoiced sets or clears voice status
// for a session in a channel.
func (database *Database) SetChannelMemberVoiced(
ctx context.Context,
channelID, sessionID int64,
isVoiced bool,
) error {
val := 0
if isVoiced {
val = 1
}
_, err := database.conn.ExecContext(ctx,
`UPDATE channel_members
SET is_voiced = ?
WHERE channel_id = ? AND session_id = ?`,
val, channelID, sessionID)
if err != nil {
return fmt.Errorf(
"set channel member voiced: %w", err,
)
}
return nil
}
// IsChannelModerated returns whether a channel has +m set.
func (database *Database) IsChannelModerated(
ctx context.Context,
channelID int64,
) (bool, error) {
var isMod int
err := database.conn.QueryRowContext(ctx,
`SELECT is_moderated FROM channels
WHERE id = ?`,
channelID,
).Scan(&isMod)
if err != nil {
return false, fmt.Errorf(
"check channel moderated: %w", err,
)
}
return isMod != 0, nil
}
// SetChannelModerated sets or clears +m on a channel.
func (database *Database) SetChannelModerated(
ctx context.Context,
channelID int64,
moderated bool,
) error {
val := 0
if moderated {
val = 1
}
_, err := database.conn.ExecContext(ctx,
`UPDATE channels
SET is_moderated = ?, updated_at = ?
WHERE id = ?`,
val, time.Now(), channelID)
if err != nil {
return fmt.Errorf(
"set channel moderated: %w", err,
)
}
return nil
}
// IsChannelTopicLocked returns whether a channel has
// +t set.
func (database *Database) IsChannelTopicLocked(
ctx context.Context,
channelID int64,
) (bool, error) {
var isLocked int
err := database.conn.QueryRowContext(ctx,
`SELECT is_topic_locked FROM channels
WHERE id = ?`,
channelID,
).Scan(&isLocked)
if err != nil {
return false, fmt.Errorf(
"check channel topic locked: %w", err,
)
}
return isLocked != 0, nil
}
// SetChannelTopicLocked sets or clears +t on a channel.
func (database *Database) SetChannelTopicLocked(
ctx context.Context,
channelID int64,
locked bool,
) error {
val := 0
if locked {
val = 1
}
_, err := database.conn.ExecContext(ctx,
`UPDATE channels
SET is_topic_locked = ?, updated_at = ?
WHERE id = ?`,
val, time.Now(), channelID)
if err != nil {
return fmt.Errorf(
"set channel topic locked: %w", err,
)
}
return nil
}
// PartChannel removes a session from a channel. // PartChannel removes a session from a channel.
func (database *Database) PartChannel( func (database *Database) PartChannel(
ctx context.Context, ctx context.Context,
@@ -547,7 +781,8 @@ func (database *Database) ChannelMembers(
) ([]MemberInfo, error) { ) ([]MemberInfo, error) {
rows, err := database.conn.QueryContext(ctx, rows, err := database.conn.QueryContext(ctx,
`SELECT s.id, s.nick, s.username, `SELECT s.id, s.nick, s.username,
s.hostname, s.last_seen s.hostname, cm.is_operator, cm.is_voiced,
s.last_seen
FROM sessions s FROM sessions s
INNER JOIN channel_members cm INNER JOIN channel_members cm
ON cm.session_id = s.id ON cm.session_id = s.id
@@ -564,11 +799,16 @@ func (database *Database) ChannelMembers(
var members []MemberInfo var members []MemberInfo
for rows.Next() { for rows.Next() {
var member MemberInfo var (
member MemberInfo
isOp int
isV int
)
err = rows.Scan( err = rows.Scan(
&member.ID, &member.Nick, &member.ID, &member.Nick,
&member.Username, &member.Hostname, &member.Username, &member.Hostname,
&isOp, &isV,
&member.LastSeen, &member.LastSeen,
) )
if err != nil { if err != nil {
@@ -577,6 +817,9 @@ func (database *Database) ChannelMembers(
) )
} }
member.IsOperator = isOp != 0
member.IsVoiced = isV != 0
members = append(members, member) members = append(members, member)
} }
@@ -1593,3 +1836,663 @@ func (database *Database) PruneSpentHashcash(
return deleted, nil return deleted, nil
} }
// --- Tier 2: Ban system (+b) ---
// BanInfo represents a channel ban entry.
type BanInfo struct {
Mask string
SetBy string
CreatedAt time.Time
}
// AddChannelBan inserts a ban mask for a channel.
func (database *Database) AddChannelBan(
ctx context.Context,
channelID int64,
mask, setBy string,
) error {
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO channel_bans
(channel_id, mask, set_by, created_at)
VALUES (?, ?, ?, ?)`,
channelID, mask, setBy, time.Now())
if err != nil {
return fmt.Errorf("add channel ban: %w", err)
}
return nil
}
// RemoveChannelBan removes a ban mask from a channel.
func (database *Database) RemoveChannelBan(
ctx context.Context,
channelID int64,
mask string,
) error {
_, err := database.conn.ExecContext(ctx,
`DELETE FROM channel_bans
WHERE channel_id = ? AND mask = ?`,
channelID, mask)
if err != nil {
return fmt.Errorf("remove channel ban: %w", err)
}
return nil
}
// ListChannelBans returns all bans for a channel.
//
//nolint:dupl // different query+type vs filtered variant
func (database *Database) ListChannelBans(
ctx context.Context,
channelID int64,
) ([]BanInfo, error) {
rows, err := database.conn.QueryContext(ctx,
`SELECT mask, set_by, created_at
FROM channel_bans
WHERE channel_id = ?
ORDER BY created_at ASC`,
channelID)
if err != nil {
return nil, fmt.Errorf("list channel bans: %w", err)
}
defer func() { _ = rows.Close() }()
var bans []BanInfo
for rows.Next() {
var ban BanInfo
if scanErr := rows.Scan(
&ban.Mask, &ban.SetBy, &ban.CreatedAt,
); scanErr != nil {
return nil, fmt.Errorf(
"scan channel ban: %w", scanErr,
)
}
bans = append(bans, ban)
}
if rowErr := rows.Err(); rowErr != nil {
return nil, fmt.Errorf(
"iterate channel bans: %w", rowErr,
)
}
return bans, nil
}
// IsSessionBanned checks if a session's hostmask matches
// any ban in the channel. Returns true if banned.
func (database *Database) IsSessionBanned(
ctx context.Context,
channelID, sessionID int64,
) (bool, error) {
// Get the session's hostmask parts.
var nick, username, hostname string
err := database.conn.QueryRowContext(ctx,
`SELECT nick, username, hostname
FROM sessions WHERE id = ?`,
sessionID,
).Scan(&nick, &username, &hostname)
if err != nil {
return false, fmt.Errorf(
"get session hostmask: %w", err,
)
}
hostmask := FormatHostmask(nick, username, hostname)
// Get all ban masks for the channel.
bans, banErr := database.ListChannelBans(ctx, channelID)
if banErr != nil {
return false, banErr
}
for _, ban := range bans {
if MatchBanMask(ban.Mask, hostmask) {
return true, nil
}
}
return false, nil
}
// MatchBanMask checks if hostmask matches a ban pattern
// using IRC-style wildcard matching (* and ?).
func MatchBanMask(pattern, hostmask string) bool {
return wildcardMatch(
strings.ToLower(pattern),
strings.ToLower(hostmask),
)
}
// wildcardMatch implements simple glob-style matching
// with * (any sequence) and ? (any single character).
func wildcardMatch(pattern, str string) bool {
for len(pattern) > 0 {
switch pattern[0] {
case '*':
// Skip consecutive asterisks.
for len(pattern) > 0 && pattern[0] == '*' {
pattern = pattern[1:]
}
if len(pattern) == 0 {
return true
}
for i := 0; i <= len(str); i++ {
if wildcardMatch(pattern, str[i:]) {
return true
}
}
return false
case '?':
if len(str) == 0 {
return false
}
pattern = pattern[1:]
str = str[1:]
default:
if len(str) == 0 || pattern[0] != str[0] {
return false
}
pattern = pattern[1:]
str = str[1:]
}
}
return len(str) == 0
}
// --- Tier 2: Invite-only (+i) ---
// IsChannelInviteOnly checks if a channel has +i mode.
func (database *Database) IsChannelInviteOnly(
ctx context.Context,
channelID int64,
) (bool, error) {
var isInviteOnly int
err := database.conn.QueryRowContext(ctx,
`SELECT is_invite_only FROM channels
WHERE id = ?`,
channelID,
).Scan(&isInviteOnly)
if err != nil {
return false, fmt.Errorf(
"check invite only: %w", err,
)
}
return isInviteOnly != 0, nil
}
// SetChannelInviteOnly sets or unsets +i mode.
func (database *Database) SetChannelInviteOnly(
ctx context.Context,
channelID int64,
inviteOnly bool,
) error {
val := 0
if inviteOnly {
val = 1
}
_, err := database.conn.ExecContext(ctx,
`UPDATE channels
SET is_invite_only = ?, updated_at = ?
WHERE id = ?`,
val, time.Now(), channelID)
if err != nil {
return fmt.Errorf(
"set invite only: %w", err,
)
}
return nil
}
// AddChannelInvite records that a session has been
// invited to a channel.
func (database *Database) AddChannelInvite(
ctx context.Context,
channelID, sessionID int64,
invitedBy string,
) error {
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO channel_invites
(channel_id, session_id, invited_by, created_at)
VALUES (?, ?, ?, ?)`,
channelID, sessionID, invitedBy, time.Now())
if err != nil {
return fmt.Errorf("add channel invite: %w", err)
}
return nil
}
// HasChannelInvite checks if a session has been invited
// to a channel.
func (database *Database) HasChannelInvite(
ctx context.Context,
channelID, sessionID int64,
) (bool, error) {
var count int
err := database.conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM channel_invites
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID,
).Scan(&count)
if err != nil {
return false, fmt.Errorf(
"check invite: %w", err,
)
}
return count > 0, nil
}
// ClearChannelInvite removes a session's invite to a
// channel (called after successful JOIN).
func (database *Database) ClearChannelInvite(
ctx context.Context,
channelID, sessionID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`DELETE FROM channel_invites
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID)
if err != nil {
return fmt.Errorf("clear invite: %w", err)
}
return nil
}
// --- Tier 2: Secret (+s) ---
// IsChannelSecret checks if a channel has +s mode.
func (database *Database) IsChannelSecret(
ctx context.Context,
channelID int64,
) (bool, error) {
var isSecret int
err := database.conn.QueryRowContext(ctx,
`SELECT is_secret FROM channels
WHERE id = ?`,
channelID,
).Scan(&isSecret)
if err != nil {
return false, fmt.Errorf(
"check secret: %w", err,
)
}
return isSecret != 0, nil
}
// SetChannelSecret sets or unsets +s mode.
func (database *Database) SetChannelSecret(
ctx context.Context,
channelID int64,
secret bool,
) error {
val := 0
if secret {
val = 1
}
_, err := database.conn.ExecContext(ctx,
`UPDATE channels
SET is_secret = ?, updated_at = ?
WHERE id = ?`,
val, time.Now(), channelID)
if err != nil {
return fmt.Errorf("set secret: %w", err)
}
return nil
}
// ListAllChannelsWithCountsFiltered returns all channels
// with member counts, excluding secret channels that
// the given session is not a member of.
//
//nolint:dupl // different query+type vs ListChannelBans
func (database *Database) ListAllChannelsWithCountsFiltered(
ctx context.Context,
sessionID int64,
) ([]ChannelInfoFull, error) {
rows, err := database.conn.QueryContext(ctx,
`SELECT c.name, COUNT(cm.id) AS member_count,
c.topic
FROM channels c
LEFT JOIN channel_members cm
ON cm.channel_id = c.id
WHERE c.is_secret = 0
OR c.id IN (
SELECT channel_id FROM channel_members
WHERE session_id = ?
)
GROUP BY c.id
ORDER BY c.name ASC`,
sessionID)
if err != nil {
return nil, fmt.Errorf(
"list channels filtered: %w", err,
)
}
defer func() { _ = rows.Close() }()
var channels []ChannelInfoFull
for rows.Next() {
var chanInfo ChannelInfoFull
if scanErr := rows.Scan(
&chanInfo.Name,
&chanInfo.MemberCount,
&chanInfo.Topic,
); scanErr != nil {
return nil, fmt.Errorf(
"scan channel: %w", scanErr,
)
}
channels = append(channels, chanInfo)
}
if rowErr := rows.Err(); rowErr != nil {
return nil, fmt.Errorf(
"iterate channels: %w", rowErr,
)
}
return channels, nil
}
// GetSessionChannelsFiltered returns channels a session
// belongs to, optionally excluding secret channels for
// WHOIS (when the querier is not in the same channel).
// If querierID == targetID, returns all channels.
func (database *Database) GetSessionChannelsFiltered(
ctx context.Context,
targetSID, querierSID int64,
) ([]ChannelInfo, error) {
// If querying yourself, return all channels.
if targetSID == querierSID {
return database.GetSessionChannels(ctx, targetSID)
}
rows, err := database.conn.QueryContext(ctx,
`SELECT c.id, c.name, c.topic
FROM channels c
JOIN channel_members cm
ON cm.channel_id = c.id
WHERE cm.session_id = ?
AND (c.is_secret = 0
OR c.id IN (
SELECT channel_id FROM channel_members
WHERE session_id = ?
))
ORDER BY c.name ASC`,
targetSID, querierSID)
if err != nil {
return nil, fmt.Errorf(
"get session channels filtered: %w", err,
)
}
defer func() { _ = rows.Close() }()
var channels []ChannelInfo
for rows.Next() {
var chanInfo ChannelInfo
if scanErr := rows.Scan(
&chanInfo.ID,
&chanInfo.Name,
&chanInfo.Topic,
); scanErr != nil {
return nil, fmt.Errorf(
"scan channel: %w", scanErr,
)
}
channels = append(channels, chanInfo)
}
if rowErr := rows.Err(); rowErr != nil {
return nil, fmt.Errorf(
"iterate channels: %w", rowErr,
)
}
return channels, nil
}
// --- Tier 2: Channel Key (+k) ---
// GetChannelKey returns the key for a channel (empty
// string means no key set).
func (database *Database) GetChannelKey(
ctx context.Context,
channelID int64,
) (string, error) {
var key string
err := database.conn.QueryRowContext(ctx,
`SELECT channel_key FROM channels
WHERE id = ?`,
channelID,
).Scan(&key)
if err != nil {
return "", fmt.Errorf("get channel key: %w", err)
}
return key, nil
}
// SetChannelKey sets or clears the key for a channel.
func (database *Database) SetChannelKey(
ctx context.Context,
channelID int64,
key string,
) error {
_, err := database.conn.ExecContext(ctx,
`UPDATE channels
SET channel_key = ?, updated_at = ?
WHERE id = ?`,
key, time.Now(), channelID)
if err != nil {
return fmt.Errorf("set channel key: %w", err)
}
return nil
}
// --- Tier 2: User Limit (+l) ---
// GetChannelUserLimit returns the user limit for a
// channel (0 means no limit).
func (database *Database) GetChannelUserLimit(
ctx context.Context,
channelID int64,
) (int, error) {
var limit int
err := database.conn.QueryRowContext(ctx,
`SELECT user_limit FROM channels
WHERE id = ?`,
channelID,
).Scan(&limit)
if err != nil {
return 0, fmt.Errorf(
"get channel user limit: %w", err,
)
}
return limit, nil
}
// SetChannelUserLimit sets the user limit for a channel.
func (database *Database) SetChannelUserLimit(
ctx context.Context,
channelID int64,
limit int,
) error {
_, err := database.conn.ExecContext(ctx,
`UPDATE channels
SET user_limit = ?, updated_at = ?
WHERE id = ?`,
limit, time.Now(), channelID)
if err != nil {
return fmt.Errorf(
"set channel user limit: %w", err,
)
}
return nil
}
// SetSessionWallops sets the wallops (+w) flag on a
// session.
func (database *Database) SetSessionWallops(
ctx context.Context,
sessionID int64,
enabled bool,
) error {
val := 0
if enabled {
val = 1
}
_, err := database.conn.ExecContext(
ctx,
`UPDATE sessions SET is_wallops = ? WHERE id = ?`,
val, sessionID,
)
if err != nil {
return fmt.Errorf("set session wallops: %w", err)
}
return nil
}
// IsSessionWallops returns whether the session has the
// wallops (+w) usermode set.
func (database *Database) IsSessionWallops(
ctx context.Context,
sessionID int64,
) (bool, error) {
var isWallops int
err := database.conn.QueryRowContext(
ctx,
`SELECT is_wallops FROM sessions WHERE id = ?`,
sessionID,
).Scan(&isWallops)
if err != nil {
return false, fmt.Errorf(
"check session wallops: %w", err,
)
}
return isWallops != 0, nil
}
// GetWallopsSessionIDs returns all session IDs that have
// the wallops (+w) usermode set.
func (database *Database) GetWallopsSessionIDs(
ctx context.Context,
) ([]int64, error) {
rows, err := database.conn.QueryContext(
ctx,
`SELECT id FROM sessions WHERE is_wallops = 1`,
)
if err != nil {
return nil, fmt.Errorf(
"get wallops sessions: %w", err,
)
}
defer func() { _ = rows.Close() }()
var ids []int64
for rows.Next() {
var sessionID int64
if scanErr := rows.Scan(&sessionID); scanErr != nil {
return nil, fmt.Errorf(
"scan wallops session: %w", scanErr,
)
}
ids = append(ids, sessionID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf(
"iterate wallops sessions: %w", err,
)
}
return ids, nil
}
// UserhostInfo holds the data needed for RPL_USERHOST.
type UserhostInfo struct {
Nick string
Username string
Hostname string
IsOper bool
AwayMessage string
}
// GetUserhostInfo returns USERHOST info for the given
// nicks. Only nicks that exist are returned.
func (database *Database) GetUserhostInfo(
ctx context.Context,
nicks []string,
) ([]UserhostInfo, error) {
if len(nicks) == 0 {
return nil, nil
}
results := make([]UserhostInfo, 0, len(nicks))
for _, nick := range nicks {
var info UserhostInfo
err := database.conn.QueryRowContext(
ctx,
`SELECT nick, username, hostname,
is_oper, away_message
FROM sessions WHERE nick = ?`,
nick,
).Scan(
&info.Nick, &info.Username, &info.Hostname,
&info.IsOper, &info.AwayMessage,
)
if err != nil {
continue // nick not found, skip
}
results = append(results, info)
}
return results, nil
}

View File

@@ -1017,3 +1017,474 @@ func TestGetOperCount(t *testing.T) {
t.Fatalf("expected 1 oper, got %d", count) t.Fatalf("expected 1 oper, got %d", count)
} }
} }
// --- Tier 2 Tests ---
func TestWildcardMatch(t *testing.T) {
t.Parallel()
tests := []struct {
pattern string
input string
match bool
}{
{"*!*@*", "nick!user@host", true},
{"*!*@*.example.com", "nick!user@foo.example.com", true},
{"*!*@*.example.com", "nick!user@other.net", false},
{"badnick!*@*", "badnick!user@host", true},
{"badnick!*@*", "goodnick!user@host", false},
{"nick!user@host", "nick!user@host", true},
{"nick!user@host", "nick!user@other", false},
{"*", "anything", true},
{"?ick!*@*", "nick!user@host", true},
{"?ick!*@*", "nn!user@host", false},
// Case-insensitive.
{"Nick!*@*", "nick!user@host", true},
}
for _, tc := range tests {
result := db.MatchBanMask(tc.pattern, tc.input)
if result != tc.match {
t.Errorf(
"MatchBanMask(%q, %q) = %v, want %v",
tc.pattern, tc.input, result, tc.match,
)
}
}
}
func TestChannelBanCRUD(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
chID, err := database.GetOrCreateChannel(ctx, "#test")
if err != nil {
t.Fatal(err)
}
// No bans initially.
bans, err := database.ListChannelBans(ctx, chID)
if err != nil {
t.Fatal(err)
}
if len(bans) != 0 {
t.Fatalf("expected 0 bans, got %d", len(bans))
}
// Add a ban.
err = database.AddChannelBan(
ctx, chID, "*!*@evil.com", "op",
)
if err != nil {
t.Fatal(err)
}
bans, err = database.ListChannelBans(ctx, chID)
if err != nil {
t.Fatal(err)
}
if len(bans) != 1 {
t.Fatalf("expected 1 ban, got %d", len(bans))
}
if bans[0].Mask != "*!*@evil.com" {
t.Fatalf("wrong mask: %s", bans[0].Mask)
}
// Duplicate add is ignored (OR IGNORE).
err = database.AddChannelBan(
ctx, chID, "*!*@evil.com", "op2",
)
if err != nil {
t.Fatal(err)
}
bans, _ = database.ListChannelBans(ctx, chID)
if len(bans) != 1 {
t.Fatalf("expected 1 ban after dup, got %d", len(bans))
}
// Remove ban.
err = database.RemoveChannelBan(
ctx, chID, "*!*@evil.com",
)
if err != nil {
t.Fatal(err)
}
bans, _ = database.ListChannelBans(ctx, chID)
if len(bans) != 0 {
t.Fatalf("expected 0 bans after remove, got %d", len(bans))
}
}
func TestIsSessionBanned(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sid, _, _, err := database.CreateSession(
ctx, "victim", "victim", "evil.com", "",
)
if err != nil {
t.Fatal(err)
}
chID, err := database.GetOrCreateChannel(ctx, "#bantest")
if err != nil {
t.Fatal(err)
}
// Not banned initially.
banned, err := database.IsSessionBanned(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
if banned {
t.Fatal("should not be banned initially")
}
// Add ban matching the hostmask.
err = database.AddChannelBan(
ctx, chID, "*!*@evil.com", "op",
)
if err != nil {
t.Fatal(err)
}
banned, err = database.IsSessionBanned(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
if !banned {
t.Fatal("should be banned")
}
}
func TestChannelInviteOnly(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
chID, err := database.GetOrCreateChannel(ctx, "#invite")
if err != nil {
t.Fatal(err)
}
// Default: not invite-only.
isIO, err := database.IsChannelInviteOnly(ctx, chID)
if err != nil {
t.Fatal(err)
}
if isIO {
t.Fatal("should not be invite-only by default")
}
// Set invite-only.
err = database.SetChannelInviteOnly(ctx, chID, true)
if err != nil {
t.Fatal(err)
}
isIO, _ = database.IsChannelInviteOnly(ctx, chID)
if !isIO {
t.Fatal("should be invite-only")
}
// Unset.
err = database.SetChannelInviteOnly(ctx, chID, false)
if err != nil {
t.Fatal(err)
}
isIO, _ = database.IsChannelInviteOnly(ctx, chID)
if isIO {
t.Fatal("should not be invite-only")
}
}
func TestChannelInviteCRUD(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sid, _, _, err := database.CreateSession(
ctx, "invited", "", "", "",
)
if err != nil {
t.Fatal(err)
}
chID, err := database.GetOrCreateChannel(ctx, "#inv")
if err != nil {
t.Fatal(err)
}
// No invite initially.
has, err := database.HasChannelInvite(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
if has {
t.Fatal("should not have invite")
}
// Add invite.
err = database.AddChannelInvite(ctx, chID, sid, "op")
if err != nil {
t.Fatal(err)
}
has, _ = database.HasChannelInvite(ctx, chID, sid)
if !has {
t.Fatal("should have invite")
}
// Clear invite.
err = database.ClearChannelInvite(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
has, _ = database.HasChannelInvite(ctx, chID, sid)
if has {
t.Fatal("invite should be cleared")
}
}
func TestChannelSecret(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
chID, err := database.GetOrCreateChannel(ctx, "#secret")
if err != nil {
t.Fatal(err)
}
// Default: not secret.
isSec, err := database.IsChannelSecret(ctx, chID)
if err != nil {
t.Fatal(err)
}
if isSec {
t.Fatal("should not be secret by default")
}
err = database.SetChannelSecret(ctx, chID, true)
if err != nil {
t.Fatal(err)
}
isSec, _ = database.IsChannelSecret(ctx, chID)
if !isSec {
t.Fatal("should be secret")
}
}
// createTestSession is a helper to create a session and
// return only the session ID.
func createTestSession(
t *testing.T,
database *db.Database,
nick string,
) int64 {
t.Helper()
sid, _, _, err := database.CreateSession(
t.Context(), nick, "", "", "",
)
if err != nil {
t.Fatalf("create session %s: %v", nick, err)
}
return sid
}
func TestSecretChannelFiltering(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
// Create two sessions.
sid1 := createTestSession(t, database, "member")
sid2 := createTestSession(t, database, "outsider")
// Create a secret channel.
chID, _ := database.GetOrCreateChannel(ctx, "#secret")
_ = database.SetChannelSecret(ctx, chID, true)
_ = database.JoinChannel(ctx, chID, sid1)
// Create a non-secret channel.
chID2, _ := database.GetOrCreateChannel(ctx, "#public")
_ = database.JoinChannel(ctx, chID2, sid1)
// Member should see both.
list, err := database.ListAllChannelsWithCountsFiltered(
ctx, sid1,
)
if err != nil {
t.Fatal(err)
}
if len(list) != 2 {
t.Fatalf("member should see 2 channels, got %d", len(list))
}
// Outsider should only see public.
list, _ = database.ListAllChannelsWithCountsFiltered(
ctx, sid2,
)
if len(list) != 1 {
t.Fatalf("outsider should see 1 channel, got %d", len(list))
}
if list[0].Name != "#public" {
t.Fatalf("outsider should see #public, got %s", list[0].Name)
}
}
func TestWhoisChannelFiltering(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sid1 := createTestSession(t, database, "target")
sid2 := createTestSession(t, database, "querier")
// Create secret channel, target joins it.
chID, _ := database.GetOrCreateChannel(ctx, "#hidden")
_ = database.SetChannelSecret(ctx, chID, true)
_ = database.JoinChannel(ctx, chID, sid1)
// Querier (non-member) should not see the channel.
channels, err := database.GetSessionChannelsFiltered(
ctx, sid1, sid2,
)
if err != nil {
t.Fatal(err)
}
if len(channels) != 0 {
t.Fatalf(
"querier should see 0 channels, got %d",
len(channels),
)
}
// Target querying self should see it.
channels, _ = database.GetSessionChannelsFiltered(
ctx, sid1, sid1,
)
if len(channels) != 1 {
t.Fatalf(
"self-query should see 1 channel, got %d",
len(channels),
)
}
}
//nolint:dupl // structurally similar to TestChannelUserLimit
func TestChannelKey(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
chID, err := database.GetOrCreateChannel(ctx, "#keyed")
if err != nil {
t.Fatal(err)
}
// Default: no key.
key, err := database.GetChannelKey(ctx, chID)
if err != nil {
t.Fatal(err)
}
if key != "" {
t.Fatalf("expected empty key, got %q", key)
}
// Set key.
err = database.SetChannelKey(ctx, chID, "secret123")
if err != nil {
t.Fatal(err)
}
key, _ = database.GetChannelKey(ctx, chID)
if key != "secret123" {
t.Fatalf("expected secret123, got %q", key)
}
// Clear key.
err = database.SetChannelKey(ctx, chID, "")
if err != nil {
t.Fatal(err)
}
key, _ = database.GetChannelKey(ctx, chID)
if key != "" {
t.Fatalf("expected empty key, got %q", key)
}
}
//nolint:dupl // structurally similar to TestChannelKey
func TestChannelUserLimit(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
chID, err := database.GetOrCreateChannel(ctx, "#limited")
if err != nil {
t.Fatal(err)
}
// Default: no limit.
limit, err := database.GetChannelUserLimit(ctx, chID)
if err != nil {
t.Fatal(err)
}
if limit != 0 {
t.Fatalf("expected 0 limit, got %d", limit)
}
// Set limit.
err = database.SetChannelUserLimit(ctx, chID, 50)
if err != nil {
t.Fatal(err)
}
limit, _ = database.GetChannelUserLimit(ctx, chID)
if limit != 50 {
t.Fatalf("expected 50, got %d", limit)
}
// Clear limit.
err = database.SetChannelUserLimit(ctx, chID, 0)
if err != nil {
t.Fatal(err)
}
limit, _ = database.GetChannelUserLimit(ctx, chID)
if limit != 0 {
t.Fatalf("expected 0, got %d", limit)
}
}

View File

@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS sessions (
hostname TEXT NOT NULL DEFAULT '', hostname TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '', ip TEXT NOT NULL DEFAULT '',
is_oper INTEGER NOT NULL DEFAULT 0, is_oper INTEGER NOT NULL DEFAULT 0,
is_wallops INTEGER NOT NULL DEFAULT 0,
password_hash TEXT NOT NULL DEFAULT '', password_hash TEXT NOT NULL DEFAULT '',
signing_key TEXT NOT NULL DEFAULT '', signing_key TEXT NOT NULL DEFAULT '',
away_message TEXT NOT NULL DEFAULT '', away_message TEXT NOT NULL DEFAULT '',
@@ -40,15 +41,45 @@ CREATE TABLE IF NOT EXISTS channels (
topic_set_by TEXT NOT NULL DEFAULT '', topic_set_by TEXT NOT NULL DEFAULT '',
topic_set_at DATETIME, topic_set_at DATETIME,
hashcash_bits INTEGER NOT NULL DEFAULT 0, hashcash_bits INTEGER NOT NULL DEFAULT 0,
is_moderated INTEGER NOT NULL DEFAULT 0,
is_topic_locked INTEGER NOT NULL DEFAULT 1,
is_invite_only INTEGER NOT NULL DEFAULT 0,
is_secret INTEGER NOT NULL DEFAULT 0,
channel_key TEXT NOT NULL DEFAULT '',
user_limit INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
-- Channel bans
CREATE TABLE IF NOT EXISTS channel_bans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
mask TEXT NOT NULL,
set_by TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(channel_id, mask)
);
CREATE INDEX IF NOT EXISTS idx_channel_bans_channel ON channel_bans(channel_id);
-- Channel invites (in-memory would be simpler but DB survives restarts)
CREATE TABLE IF NOT EXISTS channel_invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
invited_by TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(channel_id, session_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_invites_channel ON channel_invites(channel_id);
-- Channel members -- Channel members
CREATE TABLE IF NOT EXISTS channel_members ( CREATE TABLE IF NOT EXISTS channel_members (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
is_operator INTEGER NOT NULL DEFAULT 0,
is_voiced INTEGER NOT NULL DEFAULT 0,
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(channel_id, session_id) UNIQUE(channel_id, session_id)
); );

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -28,6 +28,21 @@ func (hdlr *Handlers) handleLogin(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
) { ) {
ip := clientIP(request)
if !hdlr.loginLimiter.Allow(ip) {
writer.Header().Set(
"Retry-After", "1",
)
hdlr.respondError(
writer, request,
"too many login attempts, try again later",
http.StatusTooManyRequests,
)
return
}
type loginRequest struct { type loginRequest struct {
Nick string `json:"nick"` Nick string `json:"nick"`
Password string `json:"password"` Password string `json:"password"`
@@ -58,6 +73,16 @@ func (hdlr *Handlers) handleLogin(
return return
} }
hdlr.executeLogin(
writer, request, payload.Nick, payload.Password,
)
}
func (hdlr *Handlers) executeLogin(
writer http.ResponseWriter,
request *http.Request,
nick, password string,
) {
remoteIP := clientIP(request) remoteIP := clientIP(request)
hostname := resolveHostname( hostname := resolveHostname(
@@ -67,8 +92,7 @@ func (hdlr *Handlers) handleLogin(
sessionID, clientID, token, err := sessionID, clientID, token, err :=
hdlr.params.Database.LoginUser( hdlr.params.Database.LoginUser(
request.Context(), request.Context(),
payload.Nick, nick, password,
payload.Password,
remoteIP, hostname, remoteIP, hostname,
) )
if err != nil { if err != nil {
@@ -84,20 +108,20 @@ func (hdlr *Handlers) handleLogin(
hdlr.stats.IncrConnections() hdlr.stats.IncrConnections()
hdlr.deliverMOTD( hdlr.deliverMOTD(
request, clientID, sessionID, payload.Nick, request, clientID, sessionID, nick,
) )
// Initialize channel state so the new client knows // Initialize channel state so the new client knows
// which channels the session already belongs to. // which channels the session already belongs to.
hdlr.initChannelState( hdlr.initChannelState(
request, clientID, sessionID, payload.Nick, request, clientID, sessionID, nick,
) )
hdlr.setAuthCookie(writer, request, token) hdlr.setAuthCookie(writer, request, token)
hdlr.respondJSON(writer, request, map[string]any{ hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID, "id": sessionID,
"nick": payload.Nick, "nick": nick,
}, http.StatusOK) }, http.StatusOK)
} }

View File

@@ -16,6 +16,7 @@ import (
"git.eeqj.de/sneak/neoirc/internal/hashcash" "git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/healthcheck" "git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
"git.eeqj.de/sneak/neoirc/internal/stats" "git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx" "go.uber.org/fx"
) )
@@ -49,6 +50,7 @@ type Handlers struct {
broker *broker.Broker broker *broker.Broker
hashcashVal *hashcash.Validator hashcashVal *hashcash.Validator
channelHashcash *hashcash.ChannelValidator channelHashcash *hashcash.ChannelValidator
loginLimiter *ratelimit.Limiter
stats *stats.Tracker stats *stats.Tracker
cancelCleanup context.CancelFunc cancelCleanup context.CancelFunc
} }
@@ -63,6 +65,16 @@ func New(
resource = "neoirc" resource = "neoirc"
} }
loginRate := params.Config.LoginRateLimit
if loginRate <= 0 {
loginRate = ratelimit.DefaultRate
}
loginBurst := params.Config.LoginRateBurst
if loginBurst <= 0 {
loginBurst = ratelimit.DefaultBurst
}
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
params: &params, params: &params,
log: params.Logger.Get(), log: params.Logger.Get(),
@@ -70,6 +82,7 @@ func New(
broker: broker.New(), broker: broker.New(),
hashcashVal: hashcash.NewValidator(resource), hashcashVal: hashcash.NewValidator(resource),
channelHashcash: hashcash.NewChannelValidator(), channelHashcash: hashcash.NewChannelValidator(),
loginLimiter: ratelimit.New(loginRate, loginBurst),
stats: params.Stats, stats: params.Stats,
} }
@@ -162,6 +175,10 @@ func (hdlr *Handlers) stopCleanup() {
if hdlr.cancelCleanup != nil { if hdlr.cancelCleanup != nil {
hdlr.cancelCleanup() hdlr.cancelCleanup()
} }
if hdlr.loginLimiter != nil {
hdlr.loginLimiter.Stop()
}
} }
func (hdlr *Handlers) cleanupLoop(ctx context.Context) { func (hdlr *Handlers) cleanupLoop(ctx context.Context) {

View File

@@ -0,0 +1,727 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/pkg/irc"
)
// maxUserhostNicks is the maximum number of nicks allowed
// in a single USERHOST query (RFC 2812).
const maxUserhostNicks = 5
// dispatchBodyOnlyCommand routes commands that take
// (writer, request, sessionID, clientID, nick, bodyLines).
func (hdlr *Handlers) dispatchBodyOnlyCommand(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, command string,
bodyLines func() []string,
) {
switch command {
case irc.CmdAway:
hdlr.handleAway(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdNick:
hdlr.handleNick(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdPass:
hdlr.handlePass(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdInvite:
hdlr.handleInvite(
writer, request,
sessionID, clientID, nick, bodyLines,
)
}
}
// dispatchOperCommand routes oper-related commands (OPER,
// KILL, WALLOPS) to their handlers.
func (hdlr *Handlers) dispatchOperCommand(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, command string,
bodyLines func() []string,
) {
switch command {
case irc.CmdOper:
hdlr.handleOper(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdKill:
hdlr.handleKill(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdWallops:
hdlr.handleWallops(
writer, request,
sessionID, clientID, nick, bodyLines,
)
}
}
// handleUserhost handles the USERHOST command.
// Returns user@host info for up to 5 nicks.
func (hdlr *Handlers) handleUserhost(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
ctx := request.Context()
lines := bodyLines()
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdUserhost},
"Not enough parameters",
)
return
}
// Limit to 5 nicks per RFC 2812.
nicks := lines
if len(nicks) > maxUserhostNicks {
nicks = nicks[:maxUserhostNicks]
}
infos, err := hdlr.params.Database.GetUserhostInfo(
ctx, nicks,
)
if err != nil {
hdlr.log.Error(
"userhost query failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
replyStr := hdlr.buildUserhostReply(infos)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUserHost, nick, nil,
replyStr,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// buildUserhostReply builds the RPL_USERHOST reply
// string per RFC 2812.
func (hdlr *Handlers) buildUserhostReply(
infos []db.UserhostInfo,
) string {
replies := make([]string, 0, len(infos))
for idx := range infos {
info := &infos[idx]
username := info.Username
if username == "" {
username = info.Nick
}
hostname := info.Hostname
if hostname == "" {
hostname = hdlr.serverName()
}
operStar := ""
if info.IsOper {
operStar = "*"
}
awayPrefix := "+"
if info.AwayMessage != "" {
awayPrefix = "-"
}
replies = append(replies,
info.Nick+operStar+"="+
awayPrefix+username+"@"+hostname,
)
}
return strings.Join(replies, " ")
}
// handleVersion handles the VERSION command.
// Returns the server version string.
func (hdlr *Handlers) handleVersion(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
version := hdlr.serverVersion()
// 351 RPL_VERSION
hdlr.enqueueNumeric(
ctx, clientID, irc.RplVersion, nick,
[]string{version + ".", srvName},
"",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleAdmin handles the ADMIN command.
// Returns server admin contact info.
func (hdlr *Handlers) handleAdmin(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
// 256 RPL_ADMINME
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminMe, nick,
[]string{srvName},
"Administrative info",
)
// 257 RPL_ADMINLOC1
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc1, nick, nil,
"neoirc server",
)
// 258 RPL_ADMINLOC2
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc2, nick, nil,
"IRC over HTTP",
)
// 259 RPL_ADMINEMAIL
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminEmail, nick, nil,
"admin@"+srvName,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleInfo handles the INFO command.
// Returns server software information.
func (hdlr *Handlers) handleInfo(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
version := hdlr.serverVersion()
infoLines := []string{
"neoirc — IRC semantics over HTTP",
"Version: " + version,
"Written in Go",
"Started: " +
hdlr.params.Globals.StartTime.
Format(time.RFC1123),
}
for _, line := range infoLines {
// 371 RPL_INFO
hdlr.enqueueNumeric(
ctx, clientID, irc.RplInfo, nick, nil,
line,
)
}
// 374 RPL_ENDOFINFO
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfInfo, nick, nil,
"End of /INFO list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleTime handles the TIME command.
// Returns the server's local time in RFC format.
func (hdlr *Handlers) handleTime(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
// 391 RPL_TIME
hdlr.enqueueNumeric(
ctx, clientID, irc.RplTime, nick,
[]string{srvName},
time.Now().Format(time.RFC1123),
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleKill handles the KILL command.
// Forcibly disconnects a user (oper only).
func (hdlr *Handlers) handleKill(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
ctx := request.Context()
// Check oper status.
isOper, err := hdlr.params.Database.IsSessionOper(
ctx, sessionID,
)
if err != nil || !isOper {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoPrivileges, nick, nil,
"Permission Denied- You're not an IRC operator",
)
return
}
lines := bodyLines()
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdKill},
"Not enough parameters",
)
return
}
targetNick := strings.TrimSpace(lines[0])
if targetNick == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdKill},
"Not enough parameters",
)
return
}
reason := "KILLed"
if len(lines) > 1 {
reason = lines[1]
}
targetSID, lookupErr := hdlr.params.Database.
GetSessionByNick(ctx, targetNick)
if lookupErr != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoSuchNick, nick,
[]string{targetNick},
"No such nick/channel",
)
return
}
// Do not allow killing yourself.
if targetSID == sessionID {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCantKillServer, nick, nil,
"You cannot KILL yourself",
)
return
}
hdlr.executeKillUser(
request, targetSID, targetNick, nick, reason,
)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// executeKillUser forcibly disconnects a user: broadcasts
// QUIT to their channels, parts all channels, and deletes
// the session.
func (hdlr *Handlers) executeKillUser(
request *http.Request,
targetSID int64,
targetNick, killerNick, reason string,
) {
ctx := request.Context()
quitMsg := "Killed (" + killerNick + " (" + reason + "))"
quitBody, err := json.Marshal([]string{quitMsg})
if err != nil {
hdlr.log.Error(
"marshal kill quit body", "error", err,
)
return
}
channels, _ := hdlr.params.Database.
GetSessionChannels(ctx, targetSID)
notified := map[int64]bool{}
var dbID int64
if len(channels) > 0 {
dbID, _, _ = hdlr.params.Database.InsertMessage(
ctx, irc.CmdQuit, targetNick, "",
nil, json.RawMessage(quitBody), nil,
)
}
for _, chanInfo := range channels {
memberIDs, _ := hdlr.params.Database.
GetChannelMemberIDs(ctx, chanInfo.ID)
for _, mid := range memberIDs {
if mid != targetSID && !notified[mid] {
notified[mid] = true
_ = hdlr.params.Database.EnqueueToSession(
ctx, mid, dbID,
)
hdlr.broker.Notify(mid)
}
}
_ = hdlr.params.Database.PartChannel(
ctx, chanInfo.ID, targetSID,
)
_ = hdlr.params.Database.DeleteChannelIfEmpty(
ctx, chanInfo.ID,
)
}
_ = hdlr.params.Database.DeleteSession(
ctx, targetSID,
)
}
// handleWallops handles the WALLOPS command.
// Broadcasts a message to all users with +w usermode
// (oper only).
func (hdlr *Handlers) handleWallops(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
ctx := request.Context()
// Check oper status.
isOper, err := hdlr.params.Database.IsSessionOper(
ctx, sessionID,
)
if err != nil || !isOper {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoPrivileges, nick, nil,
"Permission Denied- You're not an IRC operator",
)
return
}
lines := bodyLines()
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdWallops},
"Not enough parameters",
)
return
}
message := strings.Join(lines, " ")
wallopsSIDs, err := hdlr.params.Database.
GetWallopsSessionIDs(ctx)
if err != nil {
hdlr.log.Error(
"get wallops sessions failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
if len(wallopsSIDs) > 0 {
body, mErr := json.Marshal([]string{message})
if mErr != nil {
hdlr.log.Error(
"marshal wallops body", "error", mErr,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
_ = hdlr.fanOutSilent(
request, irc.CmdWallops, nick, "*",
json.RawMessage(body), wallopsSIDs,
)
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleUserMode handles user mode queries and changes
// (e.g., MODE nick, MODE nick +w).
func (hdlr *Handlers) handleUserMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, target string,
bodyLines func() []string,
) {
ctx := request.Context()
lines := bodyLines()
// Mode change requested.
if len(lines) > 0 {
// Users can only change their own modes.
if target != nick && target != "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUsersDoNotMatch, nick, nil,
"Can't change mode for other users",
)
return
}
hdlr.applyUserModeChange(
writer, request,
sessionID, clientID, nick, lines[0],
)
return
}
// Mode query — build the current mode string.
modeStr := hdlr.buildUserModeString(ctx, sessionID)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUmodeIs, nick, nil,
modeStr,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// buildUserModeString constructs the mode string for a
// user (e.g., "+ow" for oper+wallops).
func (hdlr *Handlers) buildUserModeString(
ctx context.Context,
sessionID int64,
) string {
modes := "+"
isOper, err := hdlr.params.Database.IsSessionOper(
ctx, sessionID,
)
if err == nil && isOper {
modes += "o"
}
isWallops, err := hdlr.params.Database.IsSessionWallops(
ctx, sessionID,
)
if err == nil && isWallops {
modes += "w"
}
return modes
}
// applyUserModeChange applies a user mode change string
// (e.g., "+w", "-w").
func (hdlr *Handlers) applyUserModeChange(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, modeStr string,
) {
ctx := request.Context()
if len(modeStr) < 2 { //nolint:mnd // +/- and mode char
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUmodeUnknownFlag, nick, nil,
"Unknown MODE flag",
)
return
}
adding := modeStr[0] == '+'
modeChar := modeStr[1:]
applied, err := hdlr.applyModeChar(
ctx, writer, request,
sessionID, clientID, nick,
modeChar, adding,
)
if err != nil || !applied {
return
}
newModes := hdlr.buildUserModeString(ctx, sessionID)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUmodeIs, nick, nil,
newModes,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// applyModeChar applies a single user mode character.
// Returns (applied, error).
func (hdlr *Handlers) applyModeChar(
ctx context.Context,
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, modeChar string,
adding bool,
) (bool, error) {
switch modeChar {
case "w":
err := hdlr.params.Database.SetSessionWallops(
ctx, sessionID, adding,
)
if err != nil {
hdlr.log.Error(
"set wallops mode failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return false, fmt.Errorf(
"set wallops: %w", err,
)
}
case "o":
// +o cannot be set via MODE, only via OPER.
if adding {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUmodeUnknownFlag, nick, nil,
"Unknown MODE flag",
)
return false, nil
}
err := hdlr.params.Database.SetSessionOper(
ctx, sessionID, false,
)
if err != nil {
hdlr.log.Error(
"clear oper mode failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return false, fmt.Errorf(
"clear oper: %w", err,
)
}
default:
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUmodeUnknownFlag, nick, nil,
"Unknown MODE flag",
)
return false, nil
}
return true, nil
}

View File

@@ -0,0 +1,982 @@
// Tests for Tier 3 utility IRC commands: USERHOST,
// VERSION, ADMIN, INFO, TIME, KILL, WALLOPS.
//
//nolint:paralleltest
package handlers_test
import (
"strings"
"testing"
)
// --- USERHOST ---
func TestUserhostSingleNick(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("alice")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "USERHOST",
bodyKey: []string{"alice"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 302 RPL_USERHOST.
msg := findNumericWithParams(msgs, "302")
if msg == nil {
t.Fatalf(
"expected RPL_USERHOST (302), got %v",
msgs,
)
}
// Body should contain "alice" with the
// nick=+user@host format.
body := getNumericBody(msg)
if !strings.Contains(body, "alice") {
t.Fatalf(
"expected body to contain 'alice', got %q",
body,
)
}
// '+' means not away.
if !strings.Contains(body, "=+") {
t.Fatalf(
"expected not-away prefix '=+', got %q",
body,
)
}
}
func TestUserhostMultipleNicks(t *testing.T) {
tserver := newTestServer(t)
token1 := tserver.createSession("bob")
token2 := tserver.createSession("carol")
_ = token2
_, lastID := tserver.pollMessages(token1, 0)
tserver.sendCommand(token1, map[string]any{
commandKey: "USERHOST",
bodyKey: []string{"bob", "carol"},
})
msgs, _ := tserver.pollMessages(token1, lastID)
msg := findNumericWithParams(msgs, "302")
if msg == nil {
t.Fatalf(
"expected RPL_USERHOST (302), got %v",
msgs,
)
}
body := getNumericBody(msg)
if !strings.Contains(body, "bob") {
t.Fatalf(
"expected body to contain 'bob', got %q",
body,
)
}
if !strings.Contains(body, "carol") {
t.Fatalf(
"expected body to contain 'carol', got %q",
body,
)
}
}
func TestUserhostNonexistentNick(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("dave")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "USERHOST",
bodyKey: []string{"nobody"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Should still get 302 but with empty body.
msg := findNumericWithParams(msgs, "302")
if msg == nil {
t.Fatalf(
"expected RPL_USERHOST (302), got %v",
msgs,
)
}
}
func TestUserhostNoParams(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("eve")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "USERHOST",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 461 ERR_NEEDMOREPARAMS.
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestUserhostShowsOper(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("opernick")
_, lastID := tserver.pollMessages(token, 0)
// Authenticate as oper.
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(token, lastID)
// USERHOST should show '*' for oper.
tserver.sendCommand(token, map[string]any{
commandKey: "USERHOST",
bodyKey: []string{"opernick"},
})
msgs, _ := tserver.pollMessages(token, lastID)
msg := findNumericWithParams(msgs, "302")
if msg == nil {
t.Fatalf(
"expected RPL_USERHOST (302), got %v",
msgs,
)
}
body := getNumericBody(msg)
if !strings.Contains(body, "opernick*=") {
t.Fatalf(
"expected oper '*' in reply, got %q",
body,
)
}
}
func TestUserhostShowsAway(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("awaynick")
_, lastID := tserver.pollMessages(token, 0)
// Set away.
tserver.sendCommand(token, map[string]any{
commandKey: "AWAY",
bodyKey: []string{"gone fishing"},
})
_, lastID = tserver.pollMessages(token, lastID)
// USERHOST should show '-' for away.
tserver.sendCommand(token, map[string]any{
commandKey: "USERHOST",
bodyKey: []string{"awaynick"},
})
msgs, _ := tserver.pollMessages(token, lastID)
msg := findNumericWithParams(msgs, "302")
if msg == nil {
t.Fatalf(
"expected RPL_USERHOST (302), got %v",
msgs,
)
}
body := getNumericBody(msg)
if !strings.Contains(body, "=-") {
t.Fatalf(
"expected away prefix '=-' in reply, got %q",
body,
)
}
}
// --- VERSION ---
func TestVersion(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("frank")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "VERSION",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 351 RPL_VERSION.
msg := findNumericWithParams(msgs, "351")
if msg == nil {
t.Fatalf(
"expected RPL_VERSION (351), got %v",
msgs,
)
}
params := getNumericParams(msg)
if len(params) == 0 {
t.Fatal("expected VERSION params, got none")
}
// First param should contain version string.
if !strings.Contains(params[0], "test") {
t.Fatalf(
"expected version to contain 'test', got %q",
params[0],
)
}
}
// --- ADMIN ---
func TestAdmin(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("grace")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "ADMIN",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 256 RPL_ADMINME.
if !findNumeric(msgs, "256") {
t.Fatalf(
"expected RPL_ADMINME (256), got %v",
msgs,
)
}
// Expect 257 RPL_ADMINLOC1.
if !findNumeric(msgs, "257") {
t.Fatalf(
"expected RPL_ADMINLOC1 (257), got %v",
msgs,
)
}
// Expect 258 RPL_ADMINLOC2.
if !findNumeric(msgs, "258") {
t.Fatalf(
"expected RPL_ADMINLOC2 (258), got %v",
msgs,
)
}
// Expect 259 RPL_ADMINEMAIL.
if !findNumeric(msgs, "259") {
t.Fatalf(
"expected RPL_ADMINEMAIL (259), got %v",
msgs,
)
}
}
// --- INFO ---
func TestInfo(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hank")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "INFO",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 371 RPL_INFO (at least one).
if !findNumeric(msgs, "371") {
t.Fatalf(
"expected RPL_INFO (371), got %v",
msgs,
)
}
// Expect 374 RPL_ENDOFINFO.
if !findNumeric(msgs, "374") {
t.Fatalf(
"expected RPL_ENDOFINFO (374), got %v",
msgs,
)
}
}
// --- TIME ---
func TestTime(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("iris")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "TIME",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 391 RPL_TIME.
msg := findNumericWithParams(msgs, "391")
if msg == nil {
t.Fatalf(
"expected RPL_TIME (391), got %v",
msgs,
)
}
}
// --- KILL ---
func TestKillSuccess(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create the victim first.
victimToken := tserver.createSession("victim")
_ = victimToken
// Create oper user.
operToken := tserver.createSession("killer")
_, lastID := tserver.pollMessages(operToken, 0)
// Authenticate as oper.
tserver.sendCommand(operToken, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(operToken, lastID)
// Kill the victim.
status, result := tserver.sendCommand(
operToken, map[string]any{
commandKey: "KILL",
bodyKey: []string{"victim", "go away"},
},
)
if status != 200 {
t.Fatalf("expected 200, got %d: %v", status, result)
}
resultStatus, _ := result[statusKey].(string)
if resultStatus != "ok" {
t.Fatalf(
"expected status ok, got %v",
result,
)
}
// Verify the victim's session is gone by trying
// to WHOIS them.
tserver.sendCommand(operToken, map[string]any{
commandKey: "WHOIS",
toKey: "victim",
})
msgs, _ := tserver.pollMessages(operToken, lastID)
// Should get 401 ERR_NOSUCHNICK.
if !findNumeric(msgs, "401") {
t.Fatalf(
"expected victim to be gone (401), got %v",
msgs,
)
}
}
func TestKillNotOper(t *testing.T) {
tserver := newTestServer(t)
_ = tserver.createSession("target")
token := tserver.createSession("notoper")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "KILL",
bodyKey: []string{"target", "no reason"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 481 ERR_NOPRIVILEGES.
if !findNumeric(msgs, "481") {
t.Fatalf(
"expected ERR_NOPRIVILEGES (481), got %v",
msgs,
)
}
}
func TestKillNoParams(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("opertest")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(token, lastID)
tserver.sendCommand(token, map[string]any{
commandKey: "KILL",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 461 ERR_NEEDMOREPARAMS.
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
// sendOperKillCommand is a helper that creates an oper
// session, authenticates, then sends KILL with the given
// target nick, and returns the resulting messages.
func sendOperKillCommand(
t *testing.T,
tserver *testServer,
operNick, targetNick string,
) []map[string]any {
t.Helper()
token := tserver.createSession(operNick)
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(token, lastID)
tserver.sendCommand(token, map[string]any{
commandKey: "KILL",
bodyKey: []string{targetNick},
})
msgs, _ := tserver.pollMessages(token, lastID)
return msgs
}
func TestKillNonexistentUser(t *testing.T) {
tserver := newTestServerWithOper(t)
msgs := sendOperKillCommand(
t, tserver, "opertest2", "ghost",
)
// Expect 401 ERR_NOSUCHNICK.
if !findNumeric(msgs, "401") {
t.Fatalf(
"expected ERR_NOSUCHNICK (401), got %v",
msgs,
)
}
}
func TestKillSelf(t *testing.T) {
tserver := newTestServerWithOper(t)
msgs := sendOperKillCommand(
t, tserver, "selfkiller", "selfkiller",
)
// Expect 483 ERR_CANTKILLSERVER.
if !findNumeric(msgs, "483") {
t.Fatalf(
"expected ERR_CANTKILLSERVER (483), got %v",
msgs,
)
}
}
func TestKillBroadcastsQuit(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create victim and join a channel.
victimToken := tserver.createSession("vuser")
tserver.sendCommand(victimToken, map[string]any{
commandKey: joinCmd,
toKey: "#killtest",
})
// Create observer and join same channel.
observerToken := tserver.createSession("observer")
tserver.sendCommand(observerToken, map[string]any{
commandKey: joinCmd,
toKey: "#killtest",
})
_, lastObs := tserver.pollMessages(observerToken, 0)
// Create oper.
operToken := tserver.createSession("theoper2")
tserver.pollMessages(operToken, 0)
tserver.sendCommand(operToken, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
tserver.pollMessages(operToken, 0)
// Kill the victim.
tserver.sendCommand(operToken, map[string]any{
commandKey: "KILL",
bodyKey: []string{"vuser", "testing kill"},
})
// Observer should see a QUIT message.
msgs, _ := tserver.pollMessages(observerToken, lastObs)
foundQuit := false
for _, msg := range msgs {
cmd, _ := msg["command"].(string)
if cmd == "QUIT" {
from, _ := msg["from"].(string)
if from == "vuser" {
foundQuit = true
break
}
}
}
if !foundQuit {
t.Fatalf(
"expected QUIT from vuser, got %v",
msgs,
)
}
}
// --- WALLOPS ---
func TestWallopsSuccess(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create receiver with +w.
receiverToken := tserver.createSession("receiver")
tserver.sendCommand(receiverToken, map[string]any{
commandKey: "MODE",
toKey: "receiver",
bodyKey: []string{"+w"},
})
_, lastRecv := tserver.pollMessages(receiverToken, 0)
// Create oper.
operToken := tserver.createSession("walloper")
tserver.pollMessages(operToken, 0)
tserver.sendCommand(operToken, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
tserver.pollMessages(operToken, 0)
// Also set +w on oper so they receive it too.
tserver.sendCommand(operToken, map[string]any{
commandKey: "MODE",
toKey: "walloper",
bodyKey: []string{"+w"},
})
tserver.pollMessages(operToken, 0)
// Send WALLOPS.
tserver.sendCommand(operToken, map[string]any{
commandKey: "WALLOPS",
bodyKey: []string{"server going down"},
})
// Receiver should get the WALLOPS message.
msgs, _ := tserver.pollMessages(receiverToken, lastRecv)
foundWallops := false
for _, msg := range msgs {
cmd, _ := msg["command"].(string)
if cmd == "WALLOPS" {
foundWallops = true
break
}
}
if !foundWallops {
t.Fatalf(
"expected WALLOPS message, got %v",
msgs,
)
}
}
func TestWallopsNotOper(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("notoper2")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "WALLOPS",
bodyKey: []string{"hello"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 481 ERR_NOPRIVILEGES.
if !findNumeric(msgs, "481") {
t.Fatalf(
"expected ERR_NOPRIVILEGES (481), got %v",
msgs,
)
}
}
func TestWallopsNoParams(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("operempty")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(token, lastID)
tserver.sendCommand(token, map[string]any{
commandKey: "WALLOPS",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 461 ERR_NEEDMOREPARAMS.
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestWallopsNotReceivedWithoutW(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create receiver WITHOUT +w.
receiverToken := tserver.createSession("nowallops")
_, lastRecv := tserver.pollMessages(receiverToken, 0)
// Create oper.
operToken := tserver.createSession("walloper2")
tserver.pollMessages(operToken, 0)
tserver.sendCommand(operToken, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
tserver.pollMessages(operToken, 0)
// Send WALLOPS.
tserver.sendCommand(operToken, map[string]any{
commandKey: "WALLOPS",
bodyKey: []string{"secret message"},
})
// Receiver should NOT get the WALLOPS message.
msgs, _ := tserver.pollMessages(receiverToken, lastRecv)
for _, msg := range msgs {
cmd, _ := msg["command"].(string)
if cmd == "WALLOPS" {
t.Fatalf(
"did not expect WALLOPS for user "+
"without +w, got %v",
msgs,
)
}
}
}
// --- User Mode +w ---
func TestUserModeSetW(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("wmoder")
_, lastID := tserver.pollMessages(token, 0)
// Set +w.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "wmoder",
bodyKey: []string{"+w"},
})
msgs, lastID := tserver.pollMessages(token, lastID)
// Expect 221 RPL_UMODEIS with "+w".
msg := findNumericWithParams(msgs, "221")
if msg == nil {
t.Fatalf(
"expected RPL_UMODEIS (221), got %v",
msgs,
)
}
body := getNumericBody(msg)
if !strings.Contains(body, "w") {
t.Fatalf(
"expected mode string to contain 'w', got %q",
body,
)
}
// Now query mode.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "wmoder",
})
msgs, _ = tserver.pollMessages(token, lastID)
msg = findNumericWithParams(msgs, "221")
if msg == nil {
t.Fatalf(
"expected RPL_UMODEIS (221) on query, got %v",
msgs,
)
}
body = getNumericBody(msg)
if !strings.Contains(body, "w") {
t.Fatalf(
"expected mode '+w' in query, got %q",
body,
)
}
}
func TestUserModeUnsetW(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("wunsetter")
_, lastID := tserver.pollMessages(token, 0)
// Set +w first.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "wunsetter",
bodyKey: []string{"+w"},
})
_, lastID = tserver.pollMessages(token, lastID)
// Unset -w.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "wunsetter",
bodyKey: []string{"-w"},
})
msgs, _ := tserver.pollMessages(token, lastID)
msg := findNumericWithParams(msgs, "221")
if msg == nil {
t.Fatalf(
"expected RPL_UMODEIS (221), got %v",
msgs,
)
}
body := getNumericBody(msg)
if strings.Contains(body, "w") {
t.Fatalf(
"expected 'w' to be removed, got %q",
body,
)
}
}
func TestUserModeUnknownFlag(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("badmode")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "badmode",
bodyKey: []string{"+z"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 501 ERR_UMODEUNKNOWNFLAG.
if !findNumeric(msgs, "501") {
t.Fatalf(
"expected ERR_UMODEUNKNOWNFLAG (501), got %v",
msgs,
)
}
}
func TestUserModeCannotSetO(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("tryoper")
_, lastID := tserver.pollMessages(token, 0)
// Try to set +o via MODE (should fail).
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "tryoper",
bodyKey: []string{"+o"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 501 ERR_UMODEUNKNOWNFLAG.
if !findNumeric(msgs, "501") {
t.Fatalf(
"expected ERR_UMODEUNKNOWNFLAG (501), got %v",
msgs,
)
}
}
func TestUserModeDeoper(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("deoper")
_, lastID := tserver.pollMessages(token, 0)
// Authenticate as oper.
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(token, lastID)
// Use MODE -o to de-oper.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "deoper",
bodyKey: []string{"-o"},
})
msgs, _ := tserver.pollMessages(token, lastID)
msg := findNumericWithParams(msgs, "221")
if msg == nil {
t.Fatalf(
"expected RPL_UMODEIS (221), got %v",
msgs,
)
}
body := getNumericBody(msg)
if strings.Contains(body, "o") {
t.Fatalf(
"expected 'o' to be removed, got %q",
body,
)
}
}
func TestUserModeCannotChangeOtherUser(t *testing.T) {
tserver := newTestServer(t)
_ = tserver.createSession("other")
token := tserver.createSession("changer")
_, lastID := tserver.pollMessages(token, 0)
// Try to change another user's mode.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "other",
bodyKey: []string{"+w"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 502 ERR_USERSDONTMATCH.
if !findNumeric(msgs, "502") {
t.Fatalf(
"expected ERR_USERSDONTMATCH (502), got %v",
msgs,
)
}
}
// getNumericBody extracts the body text from a numeric
// message. The body is stored as a JSON array; this
// returns the first element.
func getNumericBody(msg map[string]any) string {
raw, exists := msg["body"]
if !exists || raw == nil {
return ""
}
arr, isArr := raw.([]any)
if !isArr || len(arr) == 0 {
return ""
}
str, isStr := arr[0].(string)
if !isStr {
return ""
}
return str
}

View File

@@ -0,0 +1,122 @@
// Package ratelimit provides per-IP rate limiting for HTTP endpoints.
package ratelimit
import (
"sync"
"time"
"golang.org/x/time/rate"
)
const (
// DefaultRate is the default number of allowed requests per second.
DefaultRate = 1.0
// DefaultBurst is the default maximum burst size.
DefaultBurst = 5
// DefaultSweepInterval controls how often stale entries are pruned.
DefaultSweepInterval = 10 * time.Minute
// DefaultEntryTTL is how long an unused entry lives before eviction.
DefaultEntryTTL = 15 * time.Minute
)
// entry tracks a per-IP rate limiter and when it was last used.
type entry struct {
limiter *rate.Limiter
lastSeen time.Time
}
// Limiter manages per-key rate limiters with automatic cleanup
// of stale entries.
type Limiter struct {
mu sync.Mutex
entries map[string]*entry
rate rate.Limit
burst int
entryTTL time.Duration
stopCh chan struct{}
}
// New creates a new per-key rate Limiter.
// The ratePerSec parameter sets how many requests per second are
// allowed per key. The burst parameter sets the maximum number of
// requests that can be made in a single burst.
func New(ratePerSec float64, burst int) *Limiter {
limiter := &Limiter{
mu: sync.Mutex{},
entries: make(map[string]*entry),
rate: rate.Limit(ratePerSec),
burst: burst,
entryTTL: DefaultEntryTTL,
stopCh: make(chan struct{}),
}
go limiter.sweepLoop()
return limiter
}
// Allow reports whether a request from the given key should be
// allowed. It consumes one token from the key's rate limiter.
func (l *Limiter) Allow(key string) bool {
l.mu.Lock()
ent, exists := l.entries[key]
if !exists {
ent = &entry{
limiter: rate.NewLimiter(l.rate, l.burst),
lastSeen: time.Now(),
}
l.entries[key] = ent
} else {
ent.lastSeen = time.Now()
}
l.mu.Unlock()
return ent.limiter.Allow()
}
// Stop terminates the background sweep goroutine.
func (l *Limiter) Stop() {
close(l.stopCh)
}
// Len returns the number of tracked keys (for testing).
func (l *Limiter) Len() int {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.entries)
}
// sweepLoop periodically removes entries that haven't been seen
// within the TTL.
func (l *Limiter) sweepLoop() {
ticker := time.NewTicker(DefaultSweepInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
l.sweep()
case <-l.stopCh:
return
}
}
}
// sweep removes stale entries.
func (l *Limiter) sweep() {
l.mu.Lock()
defer l.mu.Unlock()
cutoff := time.Now().Add(-l.entryTTL)
for key, ent := range l.entries {
if ent.lastSeen.Before(cutoff) {
delete(l.entries, key)
}
}
}

View File

@@ -0,0 +1,106 @@
package ratelimit_test
import (
"testing"
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
)
func TestNewCreatesLimiter(t *testing.T) {
t.Parallel()
limiter := ratelimit.New(1.0, 5)
defer limiter.Stop()
if limiter == nil {
t.Fatal("expected non-nil limiter")
}
}
func TestAllowWithinBurst(t *testing.T) {
t.Parallel()
limiter := ratelimit.New(1.0, 3)
defer limiter.Stop()
for i := range 3 {
if !limiter.Allow("192.168.1.1") {
t.Fatalf(
"request %d should be allowed within burst",
i+1,
)
}
}
}
func TestAllowExceedsBurst(t *testing.T) {
t.Parallel()
// Rate of 0 means no token replenishment, only burst.
limiter := ratelimit.New(0, 3)
defer limiter.Stop()
for range 3 {
limiter.Allow("10.0.0.1")
}
if limiter.Allow("10.0.0.1") {
t.Fatal("fourth request should be denied after burst exhausted")
}
}
func TestAllowSeparateKeys(t *testing.T) {
t.Parallel()
// Rate of 0, burst of 1 — only one request per key.
limiter := ratelimit.New(0, 1)
defer limiter.Stop()
if !limiter.Allow("10.0.0.1") {
t.Fatal("first request for key A should be allowed")
}
if !limiter.Allow("10.0.0.2") {
t.Fatal("first request for key B should be allowed")
}
if limiter.Allow("10.0.0.1") {
t.Fatal("second request for key A should be denied")
}
if limiter.Allow("10.0.0.2") {
t.Fatal("second request for key B should be denied")
}
}
func TestLenTracksKeys(t *testing.T) {
t.Parallel()
limiter := ratelimit.New(1.0, 5)
defer limiter.Stop()
if limiter.Len() != 0 {
t.Fatalf("expected 0 entries, got %d", limiter.Len())
}
limiter.Allow("10.0.0.1")
limiter.Allow("10.0.0.2")
if limiter.Len() != 2 {
t.Fatalf("expected 2 entries, got %d", limiter.Len())
}
// Same key again should not increase count.
limiter.Allow("10.0.0.1")
if limiter.Len() != 2 {
t.Fatalf("expected 2 entries, got %d", limiter.Len())
}
}
func TestStopDoesNotPanic(t *testing.T) {
t.Parallel()
limiter := ratelimit.New(1.0, 5)
limiter.Stop()
}

View File

@@ -2,8 +2,13 @@ package irc
// IRC command names (RFC 1459 / RFC 2812). // IRC command names (RFC 1459 / RFC 2812).
const ( const (
CmdAdmin = "ADMIN"
CmdAway = "AWAY" CmdAway = "AWAY"
CmdInfo = "INFO"
CmdInvite = "INVITE"
CmdJoin = "JOIN" CmdJoin = "JOIN"
CmdKick = "KICK"
CmdKill = "KILL"
CmdList = "LIST" CmdList = "LIST"
CmdLusers = "LUSERS" CmdLusers = "LUSERS"
CmdMode = "MODE" CmdMode = "MODE"
@@ -18,7 +23,11 @@ const (
CmdPong = "PONG" CmdPong = "PONG"
CmdPrivmsg = "PRIVMSG" CmdPrivmsg = "PRIVMSG"
CmdQuit = "QUIT" CmdQuit = "QUIT"
CmdTime = "TIME"
CmdTopic = "TOPIC" CmdTopic = "TOPIC"
CmdUserhost = "USERHOST"
CmdVersion = "VERSION"
CmdWallops = "WALLOPS"
CmdWho = "WHO" CmdWho = "WHO"
CmdWhois = "WHOIS" CmdWhois = "WHOIS"
) )