Compare commits
2 Commits
feature/87
...
3283a72050
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3283a72050 | ||
|
|
cd9fd0c5c5 |
2
Makefile
2
Makefile
@@ -32,7 +32,7 @@ fmt-check:
|
|||||||
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
|
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
|
||||||
|
|
||||||
test: ensure-web-dist
|
test: ensure-web-dist
|
||||||
go test -timeout 30s -race -cover ./... || go test -timeout 30s -race -v ./...
|
go test -timeout 30s -v -race -cover ./...
|
||||||
|
|
||||||
# check runs all validation without making changes
|
# check runs all validation without making changes
|
||||||
# Used by CI and Docker build — fails if anything is wrong
|
# Used by CI and Docker build — fails if anything is wrong
|
||||||
|
|||||||
281
README.md
281
README.md
@@ -206,37 +206,6 @@ and password-protected sessions are deleted when the last client disconnects
|
|||||||
signatures (see [Security Model](#security-model)) remains independent of
|
signatures (see [Security Model](#security-model)) remains independent of
|
||||||
password status.
|
password status.
|
||||||
|
|
||||||
### Hostmask (nick!user@host)
|
|
||||||
|
|
||||||
Each session has an IRC-style hostmask composed of three parts:
|
|
||||||
|
|
||||||
- **nick** — the user's current nick (changes with `NICK` command)
|
|
||||||
- **username** — an ident-like identifier set at session creation (optional
|
|
||||||
`username` field in the session request; defaults to the nick)
|
|
||||||
- **hostname** — automatically resolved via reverse DNS of the connecting
|
|
||||||
client's IP address at session creation time
|
|
||||||
- **ip** — the real IP address of the session creator, extracted from
|
|
||||||
`X-Forwarded-For`, `X-Real-IP`, or `RemoteAddr`
|
|
||||||
|
|
||||||
Each **client connection** (created at session creation or login)
|
|
||||||
also stores its own **ip** and **hostname**, allowing the server to track the
|
|
||||||
network origin of each individual client independently from the session.
|
|
||||||
Client-level IP and hostname are **not displayed to regular users**. They are
|
|
||||||
only visible to **server operators** (o-line) via `RPL_WHOISACTUALLY` (338)
|
|
||||||
when the oper performs a WHOIS on a user.
|
|
||||||
|
|
||||||
The hostmask appears in:
|
|
||||||
|
|
||||||
- **WHOIS** (`311 RPL_WHOISUSER`) — `params` contains
|
|
||||||
`[nick, username, hostname, "*"]`
|
|
||||||
- **WHOIS (oper-only)** (`338 RPL_WHOISACTUALLY`) — when the querier is a
|
|
||||||
server operator, includes the target's current client IP and hostname
|
|
||||||
- **WHO** (`352 RPL_WHOREPLY`) — `params` contains
|
|
||||||
`[channel, username, hostname, server, nick, flags]`
|
|
||||||
|
|
||||||
The hostmask format (`nick!user@host`) is stored for future use in ban matching
|
|
||||||
(`+b` mode) and other access control features.
|
|
||||||
|
|
||||||
### Nick Semantics
|
### Nick Semantics
|
||||||
|
|
||||||
- Nicks are **unique per server at any point in time** — two sessions cannot
|
- Nicks are **unique per server at any point in time** — two sessions cannot
|
||||||
@@ -824,9 +793,8 @@ 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, default: ON for new channels),
|
- If the channel has mode `+t` (topic lock), only operators can change the
|
||||||
only operators (`+o`) can change the topic. Non-operators receive
|
topic (not yet enforced).
|
||||||
`ERR_CHANOPRIVSNEEDED` (482).
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
**Response:** `200 OK`
|
||||||
```json
|
```json
|
||||||
@@ -952,12 +920,7 @@ for each channel followed by RPL_LISTEND (323).
|
|||||||
#### WHOIS — User Information
|
#### WHOIS — User Information
|
||||||
|
|
||||||
Query information about a user. Returns RPL_WHOISUSER (311),
|
Query information about a user. Returns RPL_WHOISUSER (311),
|
||||||
RPL_WHOISSERVER (312), RPL_WHOISOPERATOR (313, if target is oper),
|
RPL_WHOISSERVER (312), RPL_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318).
|
||||||
RPL_WHOISIDLE (317), RPL_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318).
|
|
||||||
|
|
||||||
If the querying user is a **server operator** (authenticated via `OPER`),
|
|
||||||
the response additionally includes RPL_WHOISACTUALLY (338) with the
|
|
||||||
target's current client IP address and hostname.
|
|
||||||
|
|
||||||
**C2S:**
|
**C2S:**
|
||||||
```json
|
```json
|
||||||
@@ -992,52 +955,16 @@ LUSERS replies are also sent automatically during connection registration.
|
|||||||
|
|
||||||
**IRC reference:** RFC 1459 §4.3.2
|
**IRC reference:** RFC 1459 §4.3.2
|
||||||
|
|
||||||
#### OPER — Gain Server Operator Status
|
#### KICK — Kick User (Planned)
|
||||||
|
|
||||||
Authenticate as a server operator (o-line). On success, the session gains
|
Remove a user from a channel.
|
||||||
oper privileges, which currently means additional information is visible in
|
|
||||||
WHOIS responses (e.g., target user's current client IP and hostname).
|
|
||||||
|
|
||||||
**C2S:**
|
**C2S:**
|
||||||
```json
|
```json
|
||||||
{"command": "OPER", "body": ["opername", "operpassword"]}
|
{"command": "KICK", "to": "#general", "params": ["bob"], "body": ["misbehaving"]}
|
||||||
```
|
```
|
||||||
|
|
||||||
**S2C (via message queue on success):**
|
**Status:** Not yet implemented.
|
||||||
```json
|
|
||||||
{"command": "381", "to": "alice", "body": ["You are now an IRC operator"]}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Behavior:**
|
|
||||||
|
|
||||||
- `body[0]` is the operator name, `body[1]` is the operator password.
|
|
||||||
- The server checks against the configured `NEOIRC_OPER_NAME` and
|
|
||||||
`NEOIRC_OPER_PASSWORD` environment variables.
|
|
||||||
- On success, the session's `is_oper` flag is set and `381 RPL_YOUREOPER`
|
|
||||||
is returned.
|
|
||||||
- On failure (wrong credentials or no o-line configured), `491 ERR_NOOPERHOST`
|
|
||||||
is returned.
|
|
||||||
- Oper status persists for the session lifetime. There is no de-oper command.
|
|
||||||
|
|
||||||
**IRC reference:** RFC 1459 §4.1.5
|
|
||||||
|
|
||||||
#### KICK — Kick User
|
|
||||||
|
|
||||||
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:**
|
|
||||||
```json
|
|
||||||
{"command": "KICK", "to": "#general", "body": ["bob", "misbehaving"]}
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -1079,33 +1006,30 @@ 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","","ikmnostl"]}` |
|
| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc","0.1","","imnst"]}` |
|
||||||
| `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"]}` |
|
| `005` | RPL_ISUPPORT | After session creation | `{"command":"005","to":"alice","params":["CHANTYPES=#","NICKLEN=32","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"]}` |
|
||||||
| `254` | RPL_LUSERCHANNELS | On connect or LUSERS command | `{"command":"254","to":"alice","params":["3"],"body":["channels formed"]}` |
|
| `254` | RPL_LUSERCHANNELS | On connect or LUSERS command | `{"command":"254","to":"alice","params":["3"],"body":["channels formed"]}` |
|
||||||
| `255` | RPL_LUSERME | On connect or LUSERS command | `{"command":"255","to":"alice","body":["I have 5 clients and 1 servers"]}` |
|
| `255` | RPL_LUSERME | On connect or LUSERS command | `{"command":"255","to":"alice","body":["I have 5 clients and 1 servers"]}` |
|
||||||
| `311` | RPL_WHOISUSER | In response to WHOIS | `{"command":"311","to":"alice","params":["bob","bobident","host.example.com","*"],"body":["bob"]}` |
|
| `311` | RPL_WHOISUSER | In response to WHOIS | `{"command":"311","to":"alice","params":["bob","bob","neoirc","*"],"body":["bob"]}` |
|
||||||
| `312` | RPL_WHOISSERVER | In response to WHOIS | `{"command":"312","to":"alice","params":["bob","neoirc"],"body":["neoirc server"]}` |
|
| `312` | RPL_WHOISSERVER | In response to WHOIS | `{"command":"312","to":"alice","params":["bob","neoirc"],"body":["neoirc server"]}` |
|
||||||
| `313` | RPL_WHOISOPERATOR | In WHOIS if target is oper | `{"command":"313","to":"alice","params":["bob"],"body":["is an IRC operator"]}` |
|
|
||||||
| `315` | RPL_ENDOFWHO | End of WHO response | `{"command":"315","to":"alice","params":["#general"],"body":["End of /WHO list"]}` |
|
| `315` | RPL_ENDOFWHO | End of WHO response | `{"command":"315","to":"alice","params":["#general"],"body":["End of /WHO list"]}` |
|
||||||
| `318` | RPL_ENDOFWHOIS | End of WHOIS response | `{"command":"318","to":"alice","params":["bob"],"body":["End of /WHOIS list"]}` |
|
| `318` | RPL_ENDOFWHOIS | End of WHOIS response | `{"command":"318","to":"alice","params":["bob"],"body":["End of /WHOIS list"]}` |
|
||||||
| `319` | RPL_WHOISCHANNELS | In response to WHOIS | `{"command":"319","to":"alice","params":["bob"],"body":["#general #dev"]}` |
|
| `319` | RPL_WHOISCHANNELS | In response to WHOIS | `{"command":"319","to":"alice","params":["bob"],"body":["#general #dev"]}` |
|
||||||
| `338` | RPL_WHOISACTUALLY | In WHOIS when querier is oper | `{"command":"338","to":"alice","params":["bob","192.168.1.1"],"body":["is actually using host client.example.com"]}` |
|
|
||||||
| `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General discussion"]}` |
|
| `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General discussion"]}` |
|
||||||
| `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` |
|
| `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` |
|
||||||
| `324` | RPL_CHANNELMODEIS | In response to channel MODE query | `{"command":"324","to":"alice","params":["#general","+n"]}` |
|
| `324` | RPL_CHANNELMODEIS | In response to channel MODE query | `{"command":"324","to":"alice","params":["#general","+n"]}` |
|
||||||
| `329` | RPL_CREATIONTIME | After channel MODE query | `{"command":"329","to":"alice","params":["#general","1709251200"]}` |
|
| `329` | RPL_CREATIONTIME | After channel MODE query | `{"command":"329","to":"alice","params":["#general","1709251200"]}` |
|
||||||
| `331` | RPL_NOTOPIC | Channel has no topic (on JOIN) | `{"command":"331","to":"alice","params":["#general"],"body":["No topic is set"]}` |
|
| `331` | RPL_NOTOPIC | Channel has no topic (on JOIN) | `{"command":"331","to":"alice","params":["#general"],"body":["No topic is set"]}` |
|
||||||
| `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` |
|
| `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` |
|
||||||
| `352` | RPL_WHOREPLY | In response to WHO | `{"command":"352","to":"alice","params":["#general","bobident","host.example.com","neoirc","bob","H"],"body":["0 bob"]}` |
|
| `352` | RPL_WHOREPLY | In response to WHO | `{"command":"352","to":"alice","params":["#general","bob","neoirc","neoirc","bob","H"],"body":["0 bob"]}` |
|
||||||
| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["op1!op1@host1 alice!alice@host2 bob!bob@host3"]}` |
|
| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["@op1 alice bob +voiced1"]}` |
|
||||||
| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` |
|
| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` |
|
||||||
| `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` |
|
| `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` |
|
||||||
| `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"375","to":"alice","body":["- neoirc-server Message of the Day -"]}` |
|
| `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"375","to":"alice","body":["- neoirc-server Message of the Day -"]}` |
|
||||||
| `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"376","to":"alice","body":["End of /MOTD command"]}` |
|
| `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"376","to":"alice","body":["End of /MOTD command"]}` |
|
||||||
| `381` | RPL_YOUREOPER | Successful OPER auth | `{"command":"381","to":"alice","body":["You are now an IRC operator"]}` |
|
|
||||||
| `401` | ERR_NOSUCHNICK | DM to nonexistent nick | `{"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}` |
|
| `401` | ERR_NOSUCHNICK | DM to nonexistent nick | `{"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}` |
|
||||||
| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` |
|
| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` |
|
||||||
| `421` | ERR_UNKNOWNCOMMAND | Unrecognized command | `{"command":"421","to":"alice","params":["FOO"],"body":["Unknown command"]}` |
|
| `421` | ERR_UNKNOWNCOMMAND | Unrecognized command | `{"command":"421","to":"alice","params":["FOO"],"body":["Unknown command"]}` |
|
||||||
@@ -1114,7 +1038,6 @@ the server to the client (never C2S) and use 3-digit string codes in the
|
|||||||
| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` |
|
| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` |
|
||||||
| `461` | ERR_NEEDMOREPARAMS | Missing required fields | `{"command":"461","to":"alice","params":["JOIN"],"body":["Not enough parameters"]}` |
|
| `461` | ERR_NEEDMOREPARAMS | Missing required fields | `{"command":"461","to":"alice","params":["JOIN"],"body":["Not enough parameters"]}` |
|
||||||
| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` |
|
| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` |
|
||||||
| `491` | ERR_NOOPERHOST | Failed OPER auth | `{"command":"491","to":"alice","body":["No O-lines for your host"]}` |
|
|
||||||
|
|
||||||
**Note:** Numeric replies are now implemented. All IRC command responses
|
**Note:** Numeric replies are now implemented. All IRC command responses
|
||||||
(success and error) are delivered as numeric replies through the message queue.
|
(success and error) are delivered as numeric replies through the message queue.
|
||||||
@@ -1126,73 +1049,25 @@ carries IRC-style parameters (e.g., channel name, target nick).
|
|||||||
|
|
||||||
Inspired by IRC, simplified:
|
Inspired by IRC, simplified:
|
||||||
|
|
||||||
| Mode | Name | Meaning | Status |
|
| Mode | Name | Meaning |
|
||||||
|------|----------------|---------|--------|
|
|------|----------------|---------|
|
||||||
| `+b` | Ban | Prevents matching hostmasks from joining or sending (parameter: `nick!user@host` mask with wildcards) | **Enforced** |
|
| `+i` | Invite-only | Only invited users can join |
|
||||||
| `+i` | Invite-only | Only invited users can join; use `INVITE nick #channel` to invite | **Enforced** |
|
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send |
|
||||||
| `+k` | Channel key | Requires a password to join (parameter: key string) | **Enforced** |
|
| `+s` | Secret | Channel hidden from LIST response |
|
||||||
| `+l` | User limit | Maximum number of members allowed in the channel (parameter: integer) | **Enforced** |
|
| `+t` | Topic lock | Only operators can change the topic |
|
||||||
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** |
|
| `+n` | No external | Only channel members can send messages to the channel |
|
||||||
| `+n` | No external | Only channel members can send messages to the channel | **Enforced** |
|
| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) |
|
||||||
| `+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 | Status |
|
| Mode | Meaning | Display prefix |
|
||||||
|------|---------|----------------|--------|
|
|------|---------|----------------|
|
||||||
| `+o` | Operator | `@` in NAMES reply | **Enforced** |
|
| `+o` | Operator | `@` in NAMES reply |
|
||||||
| `+v` | Voice | `+` in NAMES reply | **Enforced** |
|
| `+v` | Voice | `+` in NAMES reply |
|
||||||
|
|
||||||
**Channel creator auto-op:** The first user to JOIN a channel (creating it)
|
**Status:** Channel modes are defined but not yet enforced. The `modes` column
|
||||||
automatically receives `+o` operator status.
|
exists in the channels table but the server does not check modes on actions.
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -1269,20 +1144,14 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
|||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
```json
|
```json
|
||||||
{"nick": "alice", "username": "alice", "pow_token": "1:20:260310:neoirc::3a2f1"}
|
{"nick": "alice", "pow_token": "1:20:260310:neoirc::3a2f1"}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Required | Constraints |
|
| Field | Type | Required | Constraints |
|
||||||
|------------|--------|-------------|-------------|
|
|------------|--------|-------------|-------------|
|
||||||
| `nick` | string | Yes | 1–32 characters, must be unique on the server |
|
| `nick` | string | Yes | 1–32 characters, must be unique on the server |
|
||||||
| `username` | string | No | 1–32 characters, IRC ident-style. Defaults to nick if omitted. |
|
|
||||||
| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
|
| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
|
||||||
|
|
||||||
The `username` field sets the user portion of the IRC hostmask
|
|
||||||
(`nick!user@host`). The hostname is automatically resolved via reverse DNS of
|
|
||||||
the connecting client's IP address at session creation time. Together these form
|
|
||||||
the hostmask used in WHOIS, WHO, and future ban matching (`+b`).
|
|
||||||
|
|
||||||
**Response:** `201 Created`
|
**Response:** `201 Created`
|
||||||
|
|
||||||
The response sets an `neoirc_auth` HttpOnly cookie containing an opaque auth
|
The response sets an `neoirc_auth` HttpOnly cookie containing an opaque auth
|
||||||
@@ -1319,7 +1188,6 @@ Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict
|
|||||||
| Status | Error | When |
|
| Status | Error | When |
|
||||||
|--------|-------|------|
|
|--------|-------|------|
|
||||||
| 400 | `nick must be 1-32 characters` | Empty or too-long nick |
|
| 400 | `nick must be 1-32 characters` | Empty or too-long nick |
|
||||||
| 400 | `invalid username format` | Username doesn't match allowed format |
|
|
||||||
| 402 | `hashcash proof-of-work required` | Missing `pow_token` field in request body when hashcash is enabled |
|
| 402 | `hashcash proof-of-work required` | Missing `pow_token` field in request body when hashcash is enabled |
|
||||||
| 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) |
|
| 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) |
|
||||||
| 409 | `nick already taken` | Another active session holds this nick |
|
| 409 | `nick already taken` | Another active session holds this nick |
|
||||||
@@ -1530,7 +1398,6 @@ reference with all required and optional fields.
|
|||||||
| `WHOIS` | `to` or `body` | | 200 OK |
|
| `WHOIS` | `to` or `body` | | 200 OK |
|
||||||
| `WHO` | `to` | | 200 OK |
|
| `WHO` | `to` | | 200 OK |
|
||||||
| `LUSERS` | | | 200 OK |
|
| `LUSERS` | | | 200 OK |
|
||||||
| `OPER` | `body` | | 200 OK |
|
|
||||||
| `QUIT` | | `body` | 200 OK |
|
| `QUIT` | | `body` | 200 OK |
|
||||||
| `PING` | | | 200 OK |
|
| `PING` | | | 200 OK |
|
||||||
|
|
||||||
@@ -1559,7 +1426,6 @@ auth cookies (401), and server errors (500).
|
|||||||
| 433 | ERR_NICKNAMEINUSE | NICK target is taken |
|
| 433 | ERR_NICKNAMEINUSE | NICK target is taken |
|
||||||
| 442 | ERR_NOTONCHANNEL | Not a member of the target channel |
|
| 442 | ERR_NOTONCHANNEL | Not a member of the target channel |
|
||||||
| 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) |
|
| 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) |
|
||||||
| 491 | ERR_NOOPERHOST | Failed OPER authentication |
|
|
||||||
|
|
||||||
**IRC numeric success replies (delivered via message queue):**
|
**IRC numeric success replies (delivered via message queue):**
|
||||||
|
|
||||||
@@ -1577,11 +1443,9 @@ auth cookies (401), and server errors (500).
|
|||||||
| 255 | RPL_LUSERME | On connect or LUSERS command |
|
| 255 | RPL_LUSERME | On connect or LUSERS command |
|
||||||
| 311 | RPL_WHOISUSER | WHOIS user info |
|
| 311 | RPL_WHOISUSER | WHOIS user info |
|
||||||
| 312 | RPL_WHOISSERVER | WHOIS server info |
|
| 312 | RPL_WHOISSERVER | WHOIS server info |
|
||||||
| 313 | RPL_WHOISOPERATOR | WHOIS target is oper |
|
|
||||||
| 315 | RPL_ENDOFWHO | End of WHO list |
|
| 315 | RPL_ENDOFWHO | End of WHO list |
|
||||||
| 318 | RPL_ENDOFWHOIS | End of WHOIS list |
|
| 318 | RPL_ENDOFWHOIS | End of WHOIS list |
|
||||||
| 319 | RPL_WHOISCHANNELS | WHOIS channels list |
|
| 319 | RPL_WHOISCHANNELS | WHOIS channels list |
|
||||||
| 338 | RPL_WHOISACTUALLY | WHOIS client IP (oper-only) |
|
|
||||||
| 322 | RPL_LIST | Channel in LIST response |
|
| 322 | RPL_LIST | Channel in LIST response |
|
||||||
| 323 | RPL_LISTEND | End of LIST |
|
| 323 | RPL_LISTEND | End of LIST |
|
||||||
| 324 | RPL_CHANNELMODEIS | Channel mode query response |
|
| 324 | RPL_CHANNELMODEIS | Channel mode query response |
|
||||||
@@ -1594,7 +1458,6 @@ auth cookies (401), and server errors (500).
|
|||||||
| 375 | RPL_MOTDSTART | Start of MOTD |
|
| 375 | RPL_MOTDSTART | Start of MOTD |
|
||||||
| 372 | RPL_MOTD | MOTD line |
|
| 372 | RPL_MOTD | MOTD line |
|
||||||
| 376 | RPL_ENDOFMOTD | End of MOTD |
|
| 376 | RPL_ENDOFMOTD | End of MOTD |
|
||||||
| 381 | RPL_YOUREOPER | Successful OPER authentication |
|
|
||||||
|
|
||||||
### GET /api/v1/history — Message History
|
### GET /api/v1/history — Message History
|
||||||
|
|
||||||
@@ -2134,20 +1997,16 @@ The database schema is managed via embedded SQL migration files in
|
|||||||
**Current tables:**
|
**Current tables:**
|
||||||
|
|
||||||
#### `sessions`
|
#### `sessions`
|
||||||
| Column | Type | Description |
|
| Column | Type | Description |
|
||||||
|----------------|----------|-------------|
|
|-----------------|----------|-------------|
|
||||||
| `id` | INTEGER | Primary key (auto-increment) |
|
| `id` | INTEGER | Primary key (auto-increment) |
|
||||||
| `uuid` | TEXT | Unique session UUID |
|
| `uuid` | TEXT | Unique session UUID |
|
||||||
| `nick` | TEXT | Unique nick |
|
| `nick` | TEXT | Unique nick |
|
||||||
| `username` | TEXT | IRC ident/username portion of the hostmask (defaults to nick) |
|
| `password_hash` | TEXT | bcrypt hash (empty string for anonymous sessions) |
|
||||||
| `hostname` | TEXT | Reverse DNS hostname of the connecting client IP |
|
| `signing_key` | TEXT | Public signing key (empty string if unset) |
|
||||||
| `ip` | TEXT | Real IP address of the session creator |
|
| `away_message` | TEXT | Away message (empty string if not away) |
|
||||||
| `is_oper` | INTEGER | Server operator (o-line) status (0 = no, 1 = yes) |
|
| `created_at` | DATETIME | Session creation time |
|
||||||
| `password_hash`| TEXT | bcrypt hash (empty string for anonymous sessions) |
|
| `last_seen` | DATETIME | Last API request time |
|
||||||
| `signing_key` | TEXT | Public signing key (empty string if unset) |
|
|
||||||
| `away_message` | TEXT | Away message (empty string if not away) |
|
|
||||||
| `created_at` | DATETIME | Session creation time |
|
|
||||||
| `last_seen` | DATETIME | Last API request time |
|
|
||||||
|
|
||||||
Index on `(uuid)`.
|
Index on `(uuid)`.
|
||||||
|
|
||||||
@@ -2158,8 +2017,6 @@ Index on `(uuid)`.
|
|||||||
| `uuid` | TEXT | Unique client UUID |
|
| `uuid` | TEXT | Unique client UUID |
|
||||||
| `session_id` | INTEGER | FK → sessions.id (cascade delete) |
|
| `session_id` | INTEGER | FK → sessions.id (cascade delete) |
|
||||||
| `token` | TEXT | Auth cookie value (SHA-256 hash of the 64-hex-char cookie) |
|
| `token` | TEXT | Auth cookie value (SHA-256 hash of the 64-hex-char cookie) |
|
||||||
| `ip` | TEXT | Real IP address of this client connection |
|
|
||||||
| `hostname` | TEXT | Reverse DNS hostname of this client connection |
|
|
||||||
| `created_at` | DATETIME | Client creation time |
|
| `created_at` | DATETIME | Client creation time |
|
||||||
| `last_seen` | DATETIME | Last API request time |
|
| `last_seen` | DATETIME | Last API request time |
|
||||||
|
|
||||||
@@ -2262,10 +2119,6 @@ directory is also loaded automatically via
|
|||||||
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
|
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
|
||||||
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
|
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
|
||||||
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
|
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
|
||||||
| `NEOIRC_OPER_NAME` | string | `""` | Server operator (o-line) username. Both name and password must be set to enable OPER. |
|
|
||||||
| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. |
|
|
||||||
| `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
|
||||||
@@ -2526,7 +2379,8 @@ 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 or NICK change).
|
- **HTTP 409**: Nick already taken (on session creation, registration, or
|
||||||
|
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).
|
||||||
|
|
||||||
@@ -2659,49 +2513,6 @@ 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
|
||||||
@@ -2733,19 +2544,17 @@ guess is borne by the server (bcrypt), not the client.
|
|||||||
- [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`
|
||||||
- [x] **Channel modes** — enforce `+m` (moderated), `+t` (topic lock), `+n` (no external)
|
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
|
||||||
- [x] **Channel modes (tier 2)** — enforce `+i` (invite-only), `+s` (secret), `+b` (ban), `+k` (key), `+l` (limit)
|
- [ ] **User channel modes** — `+o` (operator), `+v` (voice)
|
||||||
- [x] **User channel modes** — `+o` (operator), `+v` (voice) with NAMES prefixes
|
- [x] **MODE command** — query channel and user modes (set not yet implemented)
|
||||||
- [x] **KICK command** — operator-only channel kick with broadcast
|
- [x] **NAMES command** — query channel member list
|
||||||
- [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
|
||||||
- [x] **KICK command** — remove users from channels (operator-only)
|
- [ ] **KICK command** — remove users from channels
|
||||||
- [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
1
go.mod
@@ -16,7 +16,6 @@ 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
2
go.sum
@@ -151,8 +151,6 @@ 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=
|
||||||
|
|||||||
@@ -46,10 +46,6 @@ type Config struct {
|
|||||||
FederationKey string
|
FederationKey string
|
||||||
SessionIdleTimeout string
|
SessionIdleTimeout string
|
||||||
HashcashBits int
|
HashcashBits int
|
||||||
OperName string
|
|
||||||
OperPassword string
|
|
||||||
LoginRateLimit float64
|
|
||||||
LoginRateBurst int
|
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -82,10 +78,6 @@ func New(
|
|||||||
viper.SetDefault("FEDERATION_KEY", "")
|
viper.SetDefault("FEDERATION_KEY", "")
|
||||||
viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h")
|
viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h")
|
||||||
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
|
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
|
||||||
viper.SetDefault("NEOIRC_OPER_NAME", "")
|
|
||||||
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 {
|
||||||
@@ -112,10 +104,6 @@ func New(
|
|||||||
FederationKey: viper.GetString("FEDERATION_KEY"),
|
FederationKey: viper.GetString("FEDERATION_KEY"),
|
||||||
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
||||||
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
|
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
|
||||||
OperName: viper.GetString("NEOIRC_OPER_NAME"),
|
|
||||||
OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"),
|
|
||||||
LoginRateLimit: viper.GetFloat64("LOGIN_RATE_LIMIT"),
|
|
||||||
LoginRateBurst: viper.GetInt("LOGIN_RATE_BURST"),
|
|
||||||
log: log,
|
log: log,
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,7 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
//nolint:gochecknoglobals // var so tests can override via SetBcryptCost
|
const bcryptCost = bcrypt.DefaultCost
|
||||||
var bcryptCost = bcrypt.DefaultCost
|
|
||||||
|
|
||||||
// SetBcryptCost overrides the bcrypt cost.
|
|
||||||
// Use bcrypt.MinCost in tests to avoid slow hashing.
|
|
||||||
func SetBcryptCost(cost int) { bcryptCost = cost }
|
|
||||||
|
|
||||||
var errNoPassword = errors.New(
|
var errNoPassword = errors.New(
|
||||||
"account has no password set",
|
"account has no password set",
|
||||||
@@ -49,7 +44,7 @@ func (database *Database) SetPassword(
|
|||||||
// client token.
|
// client token.
|
||||||
func (database *Database) LoginUser(
|
func (database *Database) LoginUser(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nick, password, remoteIP, hostname string,
|
nick, password string,
|
||||||
) (int64, int64, string, error) {
|
) (int64, int64, string, error) {
|
||||||
var (
|
var (
|
||||||
sessionID int64
|
sessionID int64
|
||||||
@@ -96,11 +91,10 @@ func (database *Database) LoginUser(
|
|||||||
|
|
||||||
res, err := database.conn.ExecContext(ctx,
|
res, err := database.conn.ExecContext(ctx,
|
||||||
`INSERT INTO clients
|
`INSERT INTO clients
|
||||||
(uuid, session_id, token, ip, hostname,
|
(uuid, session_id, token,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, tokenHash,
|
clientUUID, sessionID, tokenHash, now, now)
|
||||||
remoteIP, hostname, now, now)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, "", fmt.Errorf(
|
return 0, 0, "", fmt.Errorf(
|
||||||
"create login client: %w", err,
|
"create login client: %w", err,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ func TestSetPassword(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sessionID, _, _, err :=
|
sessionID, _, _, err :=
|
||||||
database.CreateSession(ctx, "passuser", "", "", "")
|
database.CreateSession(ctx, "passuser")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ func TestSetPassword(t *testing.T) {
|
|||||||
|
|
||||||
// Verify we can now log in with the password.
|
// Verify we can now log in with the password.
|
||||||
loginSID, loginCID, loginToken, err :=
|
loginSID, loginCID, loginToken, err :=
|
||||||
database.LoginUser(ctx, "passuser", "password123", "", "")
|
database.LoginUser(ctx, "passuser", "password123")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ func TestSetPasswordThenWrongLogin(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sessionID, _, _, err :=
|
sessionID, _, _, err :=
|
||||||
database.CreateSession(ctx, "wrongpw", "", "", "")
|
database.CreateSession(ctx, "wrongpw")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ func TestSetPasswordThenWrongLogin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loginSID, loginCID, loginToken, loginErr :=
|
loginSID, loginCID, loginToken, loginErr :=
|
||||||
database.LoginUser(ctx, "wrongpw", "wrongpass12", "", "")
|
database.LoginUser(ctx, "wrongpw", "wrongpass12")
|
||||||
if loginErr == nil {
|
if loginErr == nil {
|
||||||
t.Fatal("expected error for wrong password")
|
t.Fatal("expected error for wrong password")
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@ func TestLoginUser(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sessionID, _, _, err :=
|
sessionID, _, _, err :=
|
||||||
database.CreateSession(ctx, "loginuser", "", "", "")
|
database.CreateSession(ctx, "loginuser")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ func TestLoginUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loginSID, loginCID, token, err :=
|
loginSID, loginCID, token, err :=
|
||||||
database.LoginUser(ctx, "loginuser", "mypassword", "", "")
|
database.LoginUser(ctx, "loginuser", "mypassword")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -116,7 +116,7 @@ func TestLoginUserNoPassword(t *testing.T) {
|
|||||||
|
|
||||||
// Create anonymous session (no password).
|
// Create anonymous session (no password).
|
||||||
anonSID, anonCID, anonToken, err :=
|
anonSID, anonCID, anonToken, err :=
|
||||||
database.CreateSession(ctx, "anon", "", "", "")
|
database.CreateSession(ctx, "anon")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -126,7 +126,7 @@ func TestLoginUserNoPassword(t *testing.T) {
|
|||||||
_ = anonToken
|
_ = anonToken
|
||||||
|
|
||||||
loginSID, loginCID, loginToken, loginErr :=
|
loginSID, loginCID, loginToken, loginErr :=
|
||||||
database.LoginUser(ctx, "anon", "anything1", "", "")
|
database.LoginUser(ctx, "anon", "anything1")
|
||||||
if loginErr == nil {
|
if loginErr == nil {
|
||||||
t.Fatal(
|
t.Fatal(
|
||||||
"expected error for login on passwordless account",
|
"expected error for login on passwordless account",
|
||||||
@@ -145,7 +145,7 @@ func TestLoginUserNonexistent(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
loginSID, loginCID, loginToken, err :=
|
loginSID, loginCID, loginToken, err :=
|
||||||
database.LoginUser(ctx, "ghost", "password123", "", "")
|
database.LoginUser(ctx, "ghost", "password123")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for nonexistent user")
|
t.Fatal("expected error for nonexistent user")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
package db_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
|
||||||
db.SetBcryptCost(bcrypt.MinCost)
|
|
||||||
os.Exit(m.Run())
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@ func TestCreateSession(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sessionID, _, token, err := database.CreateSession(
|
sessionID, _, token, err := database.CreateSession(
|
||||||
ctx, "alice", "", "", "",
|
ctx, "alice",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -45,7 +45,7 @@ func TestCreateSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, _, dupToken, dupErr := database.CreateSession(
|
_, _, dupToken, dupErr := database.CreateSession(
|
||||||
ctx, "alice", "", "", "",
|
ctx, "alice",
|
||||||
)
|
)
|
||||||
if dupErr == nil {
|
if dupErr == nil {
|
||||||
t.Fatal("expected error for duplicate nick")
|
t.Fatal("expected error for duplicate nick")
|
||||||
@@ -54,249 +54,13 @@ func TestCreateSession(t *testing.T) {
|
|||||||
_ = dupToken
|
_ = dupToken
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertSessionHostInfo creates a session and verifies
|
|
||||||
// the stored username and hostname match expectations.
|
|
||||||
func assertSessionHostInfo(
|
|
||||||
t *testing.T,
|
|
||||||
database *db.Database,
|
|
||||||
nick, inputUser, inputHost,
|
|
||||||
expectUser, expectHost string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
sessionID, _, _, err := database.CreateSession(
|
|
||||||
t.Context(), nick, inputUser, inputHost, "",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := database.GetSessionHostInfo(
|
|
||||||
t.Context(), sessionID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.Username != expectUser {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected username %s, got %s",
|
|
||||||
expectUser, info.Username,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.Hostname != expectHost {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected hostname %s, got %s",
|
|
||||||
expectHost, info.Hostname,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateSessionWithUserHost(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
|
|
||||||
assertSessionHostInfo(
|
|
||||||
t, database,
|
|
||||||
"hostuser", "myident", "example.com",
|
|
||||||
"myident", "example.com",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateSessionDefaultUsername(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
|
|
||||||
// Empty username defaults to nick.
|
|
||||||
assertSessionHostInfo(
|
|
||||||
t, database,
|
|
||||||
"defaultu", "", "host.local",
|
|
||||||
"defaultu", "host.local",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateSessionStoresIP(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sessionID, clientID, _, err := database.CreateSession(
|
|
||||||
ctx, "ipuser", "ident", "host.example.com",
|
|
||||||
"192.168.1.42",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := database.GetSessionHostInfo(
|
|
||||||
ctx, sessionID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.IP != "192.168.1.42" {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected session IP 192.168.1.42, got %s",
|
|
||||||
info.IP,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
clientInfo, err := database.GetClientHostInfo(
|
|
||||||
ctx, clientID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if clientInfo.IP != "192.168.1.42" {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected client IP 192.168.1.42, got %s",
|
|
||||||
clientInfo.IP,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if clientInfo.Hostname != "host.example.com" {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected client hostname host.example.com, got %s",
|
|
||||||
clientInfo.Hostname,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetClientHostInfoNotFound(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
|
|
||||||
_, err := database.GetClientHostInfo(
|
|
||||||
t.Context(), 99999,
|
|
||||||
)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for nonexistent client")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSessionHostInfoNotFound(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
|
|
||||||
_, err := database.GetSessionHostInfo(
|
|
||||||
t.Context(), 99999,
|
|
||||||
)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for nonexistent session")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatHostmask(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
result := db.FormatHostmask(
|
|
||||||
"nick", "user", "host.com",
|
|
||||||
)
|
|
||||||
if result != "nick!user@host.com" {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected nick!user@host.com, got %s",
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatHostmaskDefaults(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
result := db.FormatHostmask("nick", "", "")
|
|
||||||
if result != "nick!nick@*" {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected nick!nick@*, got %s",
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMemberInfoHostmask(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
member := &db.MemberInfo{ //nolint:exhaustruct // test only uses hostmask fields
|
|
||||||
Nick: "alice",
|
|
||||||
Username: "aliceident",
|
|
||||||
Hostname: "alice.example.com",
|
|
||||||
}
|
|
||||||
|
|
||||||
hostmask := member.Hostmask()
|
|
||||||
expected := "alice!aliceident@alice.example.com"
|
|
||||||
|
|
||||||
if hostmask != expected {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected %s, got %s", expected, hostmask,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChannelMembersIncludeUserHost(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sid, _, _, err := database.CreateSession(
|
|
||||||
ctx, "memuser", "myuser", "myhost.net", "",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
chID, err := database.GetOrCreateChannel(
|
|
||||||
ctx, "#hostchan",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = database.JoinChannel(ctx, chID, sid)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
members, err := database.ChannelMembers(ctx, chID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(members) != 1 {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected 1 member, got %d", len(members),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if members[0].Username != "myuser" {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected username myuser, got %s",
|
|
||||||
members[0].Username,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if members[0].Hostname != "myhost.net" {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected hostname myhost.net, got %s",
|
|
||||||
members[0].Hostname,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSessionByToken(t *testing.T) {
|
func TestGetSessionByToken(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
_, _, token, err := database.CreateSession(ctx, "bob", "", "", "")
|
_, _, token, err := database.CreateSession(ctx, "bob")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -329,7 +93,7 @@ func TestGetSessionByNick(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
charlieID, charlieClientID, charlieToken, err :=
|
charlieID, charlieClientID, charlieToken, err :=
|
||||||
database.CreateSession(ctx, "charlie", "", "", "")
|
database.CreateSession(ctx, "charlie")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -386,7 +150,7 @@ func TestJoinAndPart(t *testing.T) {
|
|||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sid, _, _, err := database.CreateSession(ctx, "user1", "", "", "")
|
sid, _, _, err := database.CreateSession(ctx, "user1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -435,7 +199,7 @@ func TestDeleteChannelIfEmpty(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sid, _, _, err := database.CreateSession(ctx, "temp", "", "", "")
|
sid, _, _, err := database.CreateSession(ctx, "temp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -470,7 +234,7 @@ func createSessionWithChannels(
|
|||||||
|
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sid, _, _, err := database.CreateSession(ctx, nick, "", "", "")
|
sid, _, _, err := database.CreateSession(ctx, nick)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -553,7 +317,7 @@ func TestChangeNick(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sid, _, token, err := database.CreateSession(
|
sid, _, token, err := database.CreateSession(
|
||||||
ctx, "old", "", "", "",
|
ctx, "old",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -637,7 +401,7 @@ func TestPollMessages(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sid, _, token, err := database.CreateSession(
|
sid, _, token, err := database.CreateSession(
|
||||||
ctx, "poller", "", "", "",
|
ctx, "poller",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -744,7 +508,7 @@ func TestDeleteSession(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sid, _, _, err := database.CreateSession(
|
sid, _, _, err := database.CreateSession(
|
||||||
ctx, "deleteme", "", "", "",
|
ctx, "deleteme",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -784,12 +548,12 @@ func TestChannelMembers(t *testing.T) {
|
|||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sid1, _, _, err := database.CreateSession(ctx, "m1", "", "", "")
|
sid1, _, _, err := database.CreateSession(ctx, "m1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sid2, _, _, err := database.CreateSession(ctx, "m2", "", "", "")
|
sid2, _, _, err := database.CreateSession(ctx, "m2")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -847,7 +611,7 @@ func TestEnqueueToClient(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
_, _, token, err := database.CreateSession(
|
_, _, token, err := database.CreateSession(
|
||||||
ctx, "enqclient", "", "", "",
|
ctx, "enqclient",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -887,604 +651,3 @@ func TestEnqueueToClient(t *testing.T) {
|
|||||||
t.Fatalf("expected 1, got %d", len(msgs))
|
t.Fatalf("expected 1, got %d", len(msgs))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSetAndCheckSessionOper(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sessionID, _, _, err := database.CreateSession(
|
|
||||||
ctx, "opernick", "", "", "",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initially not oper.
|
|
||||||
isOper, err := database.IsSessionOper(ctx, sessionID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isOper {
|
|
||||||
t.Fatal("expected session not to be oper")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set oper.
|
|
||||||
err = database.SetSessionOper(ctx, sessionID, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
isOper, err = database.IsSessionOper(ctx, sessionID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isOper {
|
|
||||||
t.Fatal("expected session to be oper")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unset oper.
|
|
||||||
err = database.SetSessionOper(ctx, sessionID, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
isOper, err = database.IsSessionOper(ctx, sessionID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isOper {
|
|
||||||
t.Fatal("expected session not to be oper")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetLatestClientForSession(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sessionID, _, _, err := database.CreateSession(
|
|
||||||
ctx, "clientnick", "", "", "10.0.0.1",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
clientInfo, err := database.GetLatestClientForSession(
|
|
||||||
ctx, sessionID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if clientInfo.IP != "10.0.0.1" {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected IP 10.0.0.1, got %s",
|
|
||||||
clientInfo.IP,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetOperCount(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
// Create two sessions.
|
|
||||||
sid1, _, _, err := database.CreateSession(
|
|
||||||
ctx, "user1", "", "", "",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sid2, _, _, err := database.CreateSession(
|
|
||||||
ctx, "user2", "", "", "",
|
|
||||||
)
|
|
||||||
_ = sid2
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initially zero opers.
|
|
||||||
count, err := database.GetOperCount(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if count != 0 {
|
|
||||||
t.Fatalf("expected 0 opers, got %d", count)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set one as oper.
|
|
||||||
err = database.SetSessionOper(ctx, sid1, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
count, err = database.GetOperCount(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if count != 1 {
|
|
||||||
t.Fatalf("expected 1 oper, got %d", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,11 +6,6 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
uuid TEXT NOT NULL UNIQUE,
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
nick TEXT NOT NULL UNIQUE,
|
nick TEXT NOT NULL UNIQUE,
|
||||||
username TEXT NOT NULL DEFAULT '',
|
|
||||||
hostname TEXT NOT NULL DEFAULT '',
|
|
||||||
ip TEXT NOT NULL DEFAULT '',
|
|
||||||
is_oper INTEGER NOT NULL DEFAULT 0,
|
|
||||||
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 '',
|
||||||
@@ -25,8 +20,6 @@ CREATE TABLE IF NOT EXISTS clients (
|
|||||||
uuid TEXT NOT NULL UNIQUE,
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
token TEXT NOT NULL UNIQUE,
|
token TEXT NOT NULL UNIQUE,
|
||||||
ip TEXT NOT NULL DEFAULT '',
|
|
||||||
hostname TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -41,45 +34,15 @@ 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
@@ -28,21 +28,6 @@ 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"`
|
||||||
@@ -73,27 +58,11 @@ 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)
|
|
||||||
|
|
||||||
hostname := resolveHostname(
|
|
||||||
request.Context(), remoteIP,
|
|
||||||
)
|
|
||||||
|
|
||||||
sessionID, clientID, token, err :=
|
sessionID, clientID, token, err :=
|
||||||
hdlr.params.Database.LoginUser(
|
hdlr.params.Database.LoginUser(
|
||||||
request.Context(),
|
request.Context(),
|
||||||
nick, password,
|
payload.Nick,
|
||||||
remoteIP, hostname,
|
payload.Password,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondError(
|
hdlr.respondError(
|
||||||
@@ -108,20 +77,20 @@ func (hdlr *Handlers) executeLogin(
|
|||||||
hdlr.stats.IncrConnections()
|
hdlr.stats.IncrConnections()
|
||||||
|
|
||||||
hdlr.deliverMOTD(
|
hdlr.deliverMOTD(
|
||||||
request, clientID, sessionID, nick,
|
request, clientID, sessionID, payload.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, nick,
|
request, clientID, sessionID, payload.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": nick,
|
"nick": payload.Nick,
|
||||||
}, http.StatusOK)
|
}, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -50,7 +49,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -65,16 +63,6 @@ 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: ¶ms,
|
params: ¶ms,
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
@@ -82,7 +70,6 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,10 +162,6 @@ 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) {
|
||||||
|
|||||||
@@ -1,727 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,982 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@@ -2,32 +2,22 @@ package irc
|
|||||||
|
|
||||||
// IRC command names (RFC 1459 / RFC 2812).
|
// IRC command names (RFC 1459 / RFC 2812).
|
||||||
const (
|
const (
|
||||||
CmdAdmin = "ADMIN"
|
CmdAway = "AWAY"
|
||||||
CmdAway = "AWAY"
|
CmdJoin = "JOIN"
|
||||||
CmdInfo = "INFO"
|
CmdList = "LIST"
|
||||||
CmdInvite = "INVITE"
|
CmdLusers = "LUSERS"
|
||||||
CmdJoin = "JOIN"
|
CmdMode = "MODE"
|
||||||
CmdKick = "KICK"
|
CmdMotd = "MOTD"
|
||||||
CmdKill = "KILL"
|
CmdNames = "NAMES"
|
||||||
CmdList = "LIST"
|
CmdNick = "NICK"
|
||||||
CmdLusers = "LUSERS"
|
CmdNotice = "NOTICE"
|
||||||
CmdMode = "MODE"
|
CmdPass = "PASS"
|
||||||
CmdMotd = "MOTD"
|
CmdPart = "PART"
|
||||||
CmdNames = "NAMES"
|
CmdPing = "PING"
|
||||||
CmdNick = "NICK"
|
CmdPong = "PONG"
|
||||||
CmdNotice = "NOTICE"
|
CmdPrivmsg = "PRIVMSG"
|
||||||
CmdOper = "OPER"
|
CmdQuit = "QUIT"
|
||||||
CmdPass = "PASS"
|
CmdTopic = "TOPIC"
|
||||||
CmdPart = "PART"
|
CmdWho = "WHO"
|
||||||
CmdPing = "PING"
|
CmdWhois = "WHOIS"
|
||||||
CmdPong = "PONG"
|
|
||||||
CmdPrivmsg = "PRIVMSG"
|
|
||||||
CmdQuit = "QUIT"
|
|
||||||
CmdTime = "TIME"
|
|
||||||
CmdTopic = "TOPIC"
|
|
||||||
CmdUserhost = "USERHOST"
|
|
||||||
CmdVersion = "VERSION"
|
|
||||||
CmdWallops = "WALLOPS"
|
|
||||||
CmdWho = "WHO"
|
|
||||||
CmdWhois = "WHOIS"
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -132,7 +132,6 @@ const (
|
|||||||
RplNoTopic IRCMessageType = 331
|
RplNoTopic IRCMessageType = 331
|
||||||
RplTopic IRCMessageType = 332
|
RplTopic IRCMessageType = 332
|
||||||
RplTopicWhoTime IRCMessageType = 333
|
RplTopicWhoTime IRCMessageType = 333
|
||||||
RplWhoisActually IRCMessageType = 338
|
|
||||||
RplInviting IRCMessageType = 341
|
RplInviting IRCMessageType = 341
|
||||||
RplSummoning IRCMessageType = 342
|
RplSummoning IRCMessageType = 342
|
||||||
RplInviteList IRCMessageType = 346
|
RplInviteList IRCMessageType = 346
|
||||||
@@ -296,7 +295,6 @@ var names = map[IRCMessageType]string{
|
|||||||
RplNoTopic: "RPL_NOTOPIC",
|
RplNoTopic: "RPL_NOTOPIC",
|
||||||
RplTopic: "RPL_TOPIC",
|
RplTopic: "RPL_TOPIC",
|
||||||
RplTopicWhoTime: "RPL_TOPICWHOTIME",
|
RplTopicWhoTime: "RPL_TOPICWHOTIME",
|
||||||
RplWhoisActually: "RPL_WHOISACTUALLY",
|
|
||||||
RplInviting: "RPL_INVITING",
|
RplInviting: "RPL_INVITING",
|
||||||
RplSummoning: "RPL_SUMMONING",
|
RplSummoning: "RPL_SUMMONING",
|
||||||
RplInviteList: "RPL_INVITELIST",
|
RplInviteList: "RPL_INVITELIST",
|
||||||
|
|||||||
Reference in New Issue
Block a user