feat: add OPER command and oper-only WHOIS client info
Some checks failed
check / check (push) Failing after 1m52s
Some checks failed
check / check (push) Failing after 1m52s
- Add OPER command with NEOIRC_OPER_NAME/NEOIRC_OPER_PASSWORD config - Add is_oper column to sessions table - Add RPL_WHOISACTUALLY (338): show client IP/hostname to opers - Add RPL_WHOISOPERATOR (313): show oper status in WHOIS - Add GetOperCount for accurate LUSERS oper count - Fix README schema: add ip/is_oper to sessions, ip/hostname to clients - Add OPER command documentation and numeric references to README - Refactor executeWhois to stay under funlen limit - Add comprehensive tests for OPER auth, oper WHOIS, non-oper WHOIS Closes #81
This commit is contained in:
56
README.md
56
README.md
@@ -222,11 +222,16 @@ Each session has an IRC-style hostmask composed of three parts:
|
|||||||
Each **client connection** (created at session creation, registration, or login)
|
Each **client connection** (created at session creation, registration, or login)
|
||||||
also stores its own **ip** and **hostname**, allowing the server to track the
|
also stores its own **ip** and **hostname**, allowing the server to track the
|
||||||
network origin of each individual client independently from the session.
|
network origin of each individual client independently from the session.
|
||||||
|
Client-level IP and hostname are **not displayed to regular users**. They are
|
||||||
|
only visible to **server operators** (o-line) via `RPL_WHOISACTUALLY` (338)
|
||||||
|
when the oper performs a WHOIS on a user.
|
||||||
|
|
||||||
The hostmask appears in:
|
The hostmask appears in:
|
||||||
|
|
||||||
- **WHOIS** (`311 RPL_WHOISUSER`) — `params` contains
|
- **WHOIS** (`311 RPL_WHOISUSER`) — `params` contains
|
||||||
`[nick, username, hostname, "*"]`
|
`[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
|
- **WHO** (`352 RPL_WHOREPLY`) — `params` contains
|
||||||
`[channel, username, hostname, server, nick, flags]`
|
`[channel, username, hostname, server, nick, flags]`
|
||||||
|
|
||||||
@@ -909,7 +914,12 @@ 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_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318).
|
RPL_WHOISSERVER (312), RPL_WHOISOPERATOR (313, if target is oper),
|
||||||
|
RPL_WHOISIDLE (317), RPL_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318).
|
||||||
|
|
||||||
|
If the querying user is a **server operator** (authenticated via `OPER`),
|
||||||
|
the response additionally includes RPL_WHOISACTUALLY (338) with the
|
||||||
|
target's current client IP address and hostname.
|
||||||
|
|
||||||
**C2S:**
|
**C2S:**
|
||||||
```json
|
```json
|
||||||
@@ -944,6 +954,35 @@ 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
|
||||||
|
|
||||||
|
Authenticate as a server operator (o-line). On success, the session gains
|
||||||
|
oper privileges, which currently means additional information is visible in
|
||||||
|
WHOIS responses (e.g., target user's current client IP and hostname).
|
||||||
|
|
||||||
|
**C2S:**
|
||||||
|
```json
|
||||||
|
{"command": "OPER", "body": ["opername", "operpassword"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**S2C (via message queue on success):**
|
||||||
|
```json
|
||||||
|
{"command": "381", "to": "alice", "body": ["You are now an IRC operator"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
|
||||||
|
- `body[0]` is the operator name, `body[1]` is the operator password.
|
||||||
|
- The server checks against the configured `NEOIRC_OPER_NAME` and
|
||||||
|
`NEOIRC_OPER_PASSWORD` environment variables.
|
||||||
|
- On success, the session's `is_oper` flag is set and `381 RPL_YOUREOPER`
|
||||||
|
is returned.
|
||||||
|
- On failure (wrong credentials or no o-line configured), `491 ERR_NOOPERHOST`
|
||||||
|
is returned.
|
||||||
|
- Oper status persists for the session lifetime. There is no de-oper command.
|
||||||
|
|
||||||
|
**IRC reference:** RFC 1459 §4.1.5
|
||||||
|
|
||||||
#### KICK — Kick User (Planned)
|
#### KICK — Kick User (Planned)
|
||||||
|
|
||||||
Remove a user from a channel.
|
Remove a user from a channel.
|
||||||
@@ -1004,9 +1043,11 @@ the server to the client (never C2S) and use 3-digit string codes in the
|
|||||||
| `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","bobident","host.example.com","*"],"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"]}` |
|
||||||
@@ -1019,6 +1060,7 @@ the server to the client (never C2S) and use 3-digit string codes in the
|
|||||||
| `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"]}` |
|
||||||
@@ -1027,6 +1069,7 @@ the server to the client (never C2S) and use 3-digit string codes in the
|
|||||||
| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` |
|
| `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.
|
||||||
@@ -1381,6 +1424,7 @@ 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 |
|
||||||
|
|
||||||
@@ -1409,6 +1453,7 @@ auth tokens (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):**
|
||||||
|
|
||||||
@@ -1426,9 +1471,11 @@ auth tokens (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 |
|
||||||
@@ -1441,6 +1488,7 @@ auth tokens (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
|
||||||
|
|
||||||
@@ -1981,6 +2029,8 @@ The database schema is managed via embedded SQL migration files in
|
|||||||
| `nick` | TEXT | Unique nick |
|
| `nick` | TEXT | Unique nick |
|
||||||
| `username` | TEXT | IRC ident/username portion of the hostmask (defaults to nick) |
|
| `username` | TEXT | IRC ident/username portion of the hostmask (defaults to nick) |
|
||||||
| `hostname` | TEXT | Reverse DNS hostname of the connecting client IP |
|
| `hostname` | TEXT | Reverse DNS hostname of the connecting client IP |
|
||||||
|
| `ip` | TEXT | Real IP address of the session creator |
|
||||||
|
| `is_oper` | INTEGER | Server operator (o-line) status (0 = no, 1 = yes) |
|
||||||
| `password_hash`| TEXT | bcrypt hash (empty string for anonymous sessions) |
|
| `password_hash`| TEXT | bcrypt hash (empty string for anonymous sessions) |
|
||||||
| `signing_key` | TEXT | Public signing key (empty string if unset) |
|
| `signing_key` | TEXT | Public signing key (empty string if unset) |
|
||||||
| `away_message` | TEXT | Away message (empty string if not away) |
|
| `away_message` | TEXT | Away message (empty string if not away) |
|
||||||
@@ -1994,6 +2044,8 @@ The database schema is managed via embedded SQL migration files in
|
|||||||
| `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 | Unique auth token (SHA-256 hash of 64 hex chars) |
|
| `token` | TEXT | Unique auth token (SHA-256 hash of 64 hex chars) |
|
||||||
|
| `ip` | TEXT | Real IP address of this client connection |
|
||||||
|
| `hostname` | TEXT | Reverse DNS hostname of this client connection |
|
||||||
| `created_at`| DATETIME | Client creation time |
|
| `created_at`| DATETIME | Client creation time |
|
||||||
| `last_seen` | DATETIME | Last API request time |
|
| `last_seen` | DATETIME | Last API request time |
|
||||||
|
|
||||||
@@ -2091,6 +2143,8 @@ directory is also loaded automatically via
|
|||||||
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
|
| `METRICS_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. |
|
||||||
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
||||||
|
|
||||||
### Example `.env` file
|
### Example `.env` file
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ type Config struct {
|
|||||||
FederationKey string
|
FederationKey string
|
||||||
SessionIdleTimeout string
|
SessionIdleTimeout string
|
||||||
HashcashBits int
|
HashcashBits int
|
||||||
|
OperName string
|
||||||
|
OperPassword string
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -78,6 +80,8 @@ 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", "")
|
||||||
|
|
||||||
err := viper.ReadInConfig()
|
err := viper.ReadInConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -104,6 +108,8 @@ 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"),
|
||||||
log: log,
|
log: log,
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,6 +298,75 @@ func (database *Database) GetClientHostInfo(
|
|||||||
return &info, nil
|
return &info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSessionOper sets the is_oper flag on a session.
|
||||||
|
func (database *Database) SetSessionOper(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
isOper bool,
|
||||||
|
) error {
|
||||||
|
val := 0
|
||||||
|
if isOper {
|
||||||
|
val = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := database.conn.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE sessions SET is_oper = ? WHERE id = ?`,
|
||||||
|
val, sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set session oper: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSessionOper returns whether the session has oper
|
||||||
|
// status.
|
||||||
|
func (database *Database) IsSessionOper(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
) (bool, error) {
|
||||||
|
var isOper int
|
||||||
|
|
||||||
|
err := database.conn.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT is_oper FROM sessions WHERE id = ?`,
|
||||||
|
sessionID,
|
||||||
|
).Scan(&isOper)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf(
|
||||||
|
"check session oper: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return isOper != 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestClientForSession returns the IP and hostname
|
||||||
|
// of the most recently created client for a session.
|
||||||
|
func (database *Database) GetLatestClientForSession(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
) (*ClientHostInfo, error) {
|
||||||
|
var info ClientHostInfo
|
||||||
|
|
||||||
|
err := database.conn.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT ip, hostname FROM clients
|
||||||
|
WHERE session_id = ?
|
||||||
|
ORDER BY created_at DESC LIMIT 1`,
|
||||||
|
sessionID,
|
||||||
|
).Scan(&info.IP, &info.Hostname)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"get latest client for session: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetChannelByName returns the channel ID for a name.
|
// GetChannelByName returns the channel ID for a name.
|
||||||
func (database *Database) GetChannelByName(
|
func (database *Database) GetChannelByName(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -951,6 +1020,26 @@ func (database *Database) GetUserCount(
|
|||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetOperCount returns the number of sessions with oper
|
||||||
|
// status.
|
||||||
|
func (database *Database) GetOperCount(
|
||||||
|
ctx context.Context,
|
||||||
|
) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
|
||||||
|
err := database.conn.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
"SELECT COUNT(*) FROM sessions WHERE is_oper = 1",
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"get oper count: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ClientCountForSession returns the number of clients
|
// ClientCountForSession returns the number of clients
|
||||||
// belonging to a session.
|
// belonging to a session.
|
||||||
func (database *Database) ClientCountForSession(
|
func (database *Database) ClientCountForSession(
|
||||||
|
|||||||
@@ -887,3 +887,133 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
username TEXT NOT NULL DEFAULT '',
|
username TEXT NOT NULL DEFAULT '',
|
||||||
hostname TEXT NOT NULL DEFAULT '',
|
hostname TEXT NOT NULL DEFAULT '',
|
||||||
ip TEXT NOT NULL DEFAULT '',
|
ip TEXT NOT NULL DEFAULT '',
|
||||||
|
is_oper INTEGER NOT NULL DEFAULT 0,
|
||||||
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 '',
|
||||||
|
|||||||
@@ -460,9 +460,19 @@ func (hdlr *Handlers) deliverLusers(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 252 RPL_LUSEROP
|
// 252 RPL_LUSEROP
|
||||||
|
operCount, operErr := hdlr.params.Database.
|
||||||
|
GetOperCount(ctx)
|
||||||
|
if operErr != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"lusers oper count", "error", operErr,
|
||||||
|
)
|
||||||
|
|
||||||
|
operCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplLuserOp, nick,
|
ctx, clientID, irc.RplLuserOp, nick,
|
||||||
[]string{"0"},
|
[]string{strconv.FormatInt(operCount, 10)},
|
||||||
"operator(s) online",
|
"operator(s) online",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -992,6 +1002,11 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
hdlr.handleQuit(
|
hdlr.handleQuit(
|
||||||
writer, request, sessionID, nick, body,
|
writer, request, sessionID, nick, body,
|
||||||
)
|
)
|
||||||
|
case irc.CmdOper:
|
||||||
|
hdlr.handleOper(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
|
)
|
||||||
case irc.CmdMotd, irc.CmdPing:
|
case irc.CmdMotd, irc.CmdPing:
|
||||||
hdlr.dispatchInfoCommand(
|
hdlr.dispatchInfoCommand(
|
||||||
writer, request,
|
writer, request,
|
||||||
@@ -2198,12 +2213,63 @@ func (hdlr *Handlers) executeWhois(
|
|||||||
nick, queryNick string,
|
nick, queryNick string,
|
||||||
) {
|
) {
|
||||||
ctx := request.Context()
|
ctx := request.Context()
|
||||||
srvName := hdlr.serverName()
|
|
||||||
|
|
||||||
targetSID, err := hdlr.params.Database.GetSessionByNick(
|
targetSID, err := hdlr.params.Database.GetSessionByNick(
|
||||||
ctx, queryNick,
|
ctx, queryNick,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
hdlr.whoisNotFound(
|
||||||
|
ctx, writer, request,
|
||||||
|
sessionID, clientID, nick, queryNick,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.deliverWhoisUser(
|
||||||
|
ctx, clientID, nick, queryNick, targetSID,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 313 RPL_WHOISOPERATOR — show if target is oper.
|
||||||
|
hdlr.deliverWhoisOperator(
|
||||||
|
ctx, clientID, nick, queryNick, targetSID,
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.deliverWhoisIdle(
|
||||||
|
ctx, clientID, nick, queryNick, targetSID,
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.deliverWhoisChannels(
|
||||||
|
ctx, clientID, nick, queryNick, targetSID,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 338 RPL_WHOISACTUALLY — oper-only.
|
||||||
|
hdlr.deliverWhoisActually(
|
||||||
|
ctx, clientID, nick, queryNick,
|
||||||
|
sessionID, targetSID,
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplEndOfWhois, nick,
|
||||||
|
[]string{queryNick},
|
||||||
|
"End of /WHOIS list",
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// whoisNotFound sends the error+end numerics when the
|
||||||
|
// target nick is not found.
|
||||||
|
func (hdlr *Handlers) whoisNotFound(
|
||||||
|
ctx context.Context,
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick, queryNick string,
|
||||||
|
) {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.ErrNoSuchNick, nick,
|
ctx, clientID, irc.ErrNoSuchNick, nick,
|
||||||
[]string{queryNick},
|
[]string{queryNick},
|
||||||
@@ -2218,11 +2284,18 @@ func (hdlr *Handlers) executeWhois(
|
|||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
map[string]string{"status": "ok"},
|
map[string]string{"status": "ok"},
|
||||||
http.StatusOK)
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
return
|
// deliverWhoisUser sends RPL_WHOISUSER (311) and
|
||||||
}
|
// RPL_WHOISSERVER (312).
|
||||||
|
func (hdlr *Handlers) deliverWhoisUser(
|
||||||
|
ctx context.Context,
|
||||||
|
clientID int64,
|
||||||
|
nick, queryNick string,
|
||||||
|
targetSID int64,
|
||||||
|
) {
|
||||||
|
srvName := hdlr.serverName()
|
||||||
|
|
||||||
// Look up username and hostname for the target.
|
|
||||||
username := queryNick
|
username := queryNick
|
||||||
hostname := srvName
|
hostname := srvName
|
||||||
|
|
||||||
@@ -2238,41 +2311,38 @@ func (hdlr *Handlers) executeWhois(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 311 RPL_WHOISUSER
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplWhoisUser, nick,
|
ctx, clientID, irc.RplWhoisUser, nick,
|
||||||
[]string{queryNick, username, hostname, "*"},
|
[]string{queryNick, username, hostname, "*"},
|
||||||
queryNick,
|
queryNick,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 312 RPL_WHOISSERVER
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplWhoisServer, nick,
|
ctx, clientID, irc.RplWhoisServer, nick,
|
||||||
[]string{queryNick, srvName},
|
[]string{queryNick, srvName},
|
||||||
"neoirc server",
|
"neoirc server",
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 317 RPL_WHOISIDLE
|
// deliverWhoisOperator sends RPL_WHOISOPERATOR (313) if
|
||||||
hdlr.deliverWhoisIdle(
|
// the target has server oper status.
|
||||||
ctx, clientID, nick, queryNick, targetSID,
|
func (hdlr *Handlers) deliverWhoisOperator(
|
||||||
)
|
ctx context.Context,
|
||||||
|
clientID int64,
|
||||||
|
nick, queryNick string,
|
||||||
|
targetSID int64,
|
||||||
|
) {
|
||||||
|
targetIsOper, err := hdlr.params.Database.
|
||||||
|
IsSessionOper(ctx, targetSID)
|
||||||
|
if err != nil || !targetIsOper {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 319 RPL_WHOISCHANNELS
|
|
||||||
hdlr.deliverWhoisChannels(
|
|
||||||
ctx, clientID, nick, queryNick, targetSID,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 318 RPL_ENDOFWHOIS
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplEndOfWhois, nick,
|
ctx, clientID, irc.RplWhoisOperator, nick,
|
||||||
[]string{queryNick},
|
[]string{queryNick},
|
||||||
"End of /WHOIS list",
|
"is an IRC operator",
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) deliverWhoisChannels(
|
func (hdlr *Handlers) deliverWhoisChannels(
|
||||||
@@ -2300,6 +2370,44 @@ func (hdlr *Handlers) deliverWhoisChannels(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deliverWhoisActually sends RPL_WHOISACTUALLY (338)
|
||||||
|
// with the target's current client IP and hostname, but
|
||||||
|
// only when the querying session has server oper status
|
||||||
|
// (o-line). Non-opers see nothing extra.
|
||||||
|
func (hdlr *Handlers) deliverWhoisActually(
|
||||||
|
ctx context.Context,
|
||||||
|
clientID int64,
|
||||||
|
nick, queryNick string,
|
||||||
|
querierSID, targetSID int64,
|
||||||
|
) {
|
||||||
|
isOper, err := hdlr.params.Database.IsSessionOper(
|
||||||
|
ctx, querierSID,
|
||||||
|
)
|
||||||
|
if err != nil || !isOper {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientInfo, clErr := hdlr.params.Database.
|
||||||
|
GetLatestClientForSession(ctx, targetSID)
|
||||||
|
if clErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actualHost := clientInfo.Hostname
|
||||||
|
if actualHost == "" {
|
||||||
|
actualHost = clientInfo.IP
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplWhoisActually, nick,
|
||||||
|
[]string{
|
||||||
|
queryNick,
|
||||||
|
clientInfo.IP,
|
||||||
|
},
|
||||||
|
"is actually using host "+actualHost,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// handleWho handles the WHO command.
|
// handleWho handles the WHO command.
|
||||||
func (hdlr *Handlers) handleWho(
|
func (hdlr *Handlers) handleWho(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
@@ -2687,6 +2795,74 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
|
|||||||
|
|
||||||
// handleAway handles the AWAY command. An empty body
|
// handleAway handles the AWAY command. An empty body
|
||||||
// clears the away status; a non-empty body sets it.
|
// clears the away status; a non-empty body sets it.
|
||||||
|
func (hdlr *Handlers) handleOper(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
lines := bodyLines()
|
||||||
|
if len(lines) < 2 { //nolint:mnd // name + password
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrNeedMoreParams, nick,
|
||||||
|
[]string{irc.CmdOper},
|
||||||
|
"Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
operName := lines[0]
|
||||||
|
operPass := lines[1]
|
||||||
|
|
||||||
|
cfgName := hdlr.params.Config.OperName
|
||||||
|
cfgPass := hdlr.params.Config.OperPassword
|
||||||
|
|
||||||
|
if cfgName == "" || cfgPass == "" ||
|
||||||
|
operName != cfgName || operPass != cfgPass {
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.ErrNoOperHost, nick,
|
||||||
|
nil, "No O-lines for your host",
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "error"},
|
||||||
|
http.StatusOK)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := hdlr.params.Database.SetSessionOper(
|
||||||
|
ctx, sessionID, true,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"set oper failed", "error", err,
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request, "internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 381 RPL_YOUREOPER
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplYoureOper, nick,
|
||||||
|
nil, "You are now an IRC operator",
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) handleAway(
|
func (hdlr *Handlers) handleAway(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
|
|||||||
@@ -2532,3 +2532,315 @@ func assertNamesHostmask(
|
|||||||
targetNick, msgs,
|
targetNick, msgs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const testOperName = "admin"
|
||||||
|
const testOperPassword = "secretpass"
|
||||||
|
|
||||||
|
// newTestServerWithOper creates a test server with oper
|
||||||
|
// credentials configured (admin / secretpass).
|
||||||
|
func newTestServerWithOper(
|
||||||
|
t *testing.T,
|
||||||
|
) *testServer {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dbPath := filepath.Join(
|
||||||
|
t.TempDir(), "test.db",
|
||||||
|
)
|
||||||
|
|
||||||
|
dbURL := "file:" + dbPath +
|
||||||
|
"?_journal_mode=WAL&_busy_timeout=5000"
|
||||||
|
|
||||||
|
var srv *server.Server
|
||||||
|
|
||||||
|
app := fxtest.New(t,
|
||||||
|
fx.Provide(
|
||||||
|
newTestGlobals,
|
||||||
|
logger.New,
|
||||||
|
func(
|
||||||
|
lifecycle fx.Lifecycle,
|
||||||
|
globs *globals.Globals,
|
||||||
|
log *logger.Logger,
|
||||||
|
) (*config.Config, error) {
|
||||||
|
cfg, err := config.New(
|
||||||
|
lifecycle, config.Params{ //nolint:exhaustruct
|
||||||
|
Globals: globs, Logger: log,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"test config: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.DBURL = dbURL
|
||||||
|
cfg.Port = 0
|
||||||
|
cfg.HashcashBits = 0
|
||||||
|
cfg.OperName = testOperName
|
||||||
|
cfg.OperPassword = testOperPassword
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
},
|
||||||
|
newTestDB,
|
||||||
|
stats.New,
|
||||||
|
newTestHealthcheck,
|
||||||
|
newTestMiddleware,
|
||||||
|
newTestHandlers,
|
||||||
|
newTestServerFx,
|
||||||
|
),
|
||||||
|
fx.Populate(&srv),
|
||||||
|
)
|
||||||
|
|
||||||
|
const startupDelay = 100 * time.Millisecond
|
||||||
|
|
||||||
|
app.RequireStart()
|
||||||
|
time.Sleep(startupDelay)
|
||||||
|
|
||||||
|
httpSrv := httptest.NewServer(srv)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
httpSrv.Close()
|
||||||
|
app.RequireStop()
|
||||||
|
})
|
||||||
|
|
||||||
|
return &testServer{
|
||||||
|
httpServer: httpSrv,
|
||||||
|
t: t,
|
||||||
|
fxApp: app,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperCommandSuccess(t *testing.T) {
|
||||||
|
tserver := newTestServerWithOper(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("operuser")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
// Send OPER command.
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "OPER",
|
||||||
|
bodyKey: []string{testOperName, testOperPassword},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Expect 381 RPL_YOUREOPER.
|
||||||
|
if !findNumeric(msgs, "381") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_YOUREOPER (381), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperCommandFailure(t *testing.T) {
|
||||||
|
tserver := newTestServerWithOper(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("badoper")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
// Send OPER with wrong password.
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "OPER",
|
||||||
|
bodyKey: []string{testOperName, "wrongpass"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Expect 491 ERR_NOOPERHOST.
|
||||||
|
if !findNumeric(msgs, "491") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NOOPERHOST (491), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperCommandNeedMoreParams(t *testing.T) {
|
||||||
|
tserver := newTestServerWithOper(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("shortoper")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
// Send OPER with only one parameter.
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "OPER",
|
||||||
|
bodyKey: []string{testOperName},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Expect 461 ERR_NEEDMOREPARAMS.
|
||||||
|
if !findNumeric(msgs, "461") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperWhoisShowsClientInfo(t *testing.T) {
|
||||||
|
tserver := newTestServerWithOper(t)
|
||||||
|
|
||||||
|
// Create a target user.
|
||||||
|
_ = tserver.createSession("target")
|
||||||
|
|
||||||
|
// Create an oper user.
|
||||||
|
operToken := tserver.createSession("theoper")
|
||||||
|
_, lastID := tserver.pollMessages(operToken, 0)
|
||||||
|
|
||||||
|
// Authenticate as oper.
|
||||||
|
tserver.sendCommand(operToken, map[string]any{
|
||||||
|
commandKey: "OPER",
|
||||||
|
bodyKey: []string{testOperName, testOperPassword},
|
||||||
|
})
|
||||||
|
|
||||||
|
var msgs []map[string]any
|
||||||
|
|
||||||
|
msgs, lastID = tserver.pollMessages(operToken, lastID)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "381") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_YOUREOPER (381), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now WHOIS the target.
|
||||||
|
tserver.sendCommand(operToken, map[string]any{
|
||||||
|
commandKey: "WHOIS",
|
||||||
|
toKey: "target",
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ = tserver.pollMessages(operToken, lastID)
|
||||||
|
|
||||||
|
// Expect 338 RPL_WHOISACTUALLY with client IP.
|
||||||
|
whoisActually := findNumericWithParams(msgs, "338")
|
||||||
|
if whoisActually == nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_WHOISACTUALLY (338) for "+
|
||||||
|
"oper WHOIS, got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
params := getNumericParams(whoisActually)
|
||||||
|
if len(params) < 2 {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected at least 2 params in 338, "+
|
||||||
|
"got %v",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First param should be the target nick.
|
||||||
|
if params[0] != "target" {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected first param 'target', got %s",
|
||||||
|
params[0],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second param should be a non-empty IP.
|
||||||
|
if params[1] == "" {
|
||||||
|
t.Fatal("expected non-empty IP in 338 params")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonOperWhoisHidesClientInfo(t *testing.T) {
|
||||||
|
tserver := newTestServerWithOper(t)
|
||||||
|
|
||||||
|
// Create a target user.
|
||||||
|
_ = tserver.createSession("hidden")
|
||||||
|
|
||||||
|
// Create a regular (non-oper) user.
|
||||||
|
regToken := tserver.createSession("regular")
|
||||||
|
_, lastID := tserver.pollMessages(regToken, 0)
|
||||||
|
|
||||||
|
// WHOIS the target without oper status.
|
||||||
|
tserver.sendCommand(regToken, map[string]any{
|
||||||
|
commandKey: "WHOIS",
|
||||||
|
toKey: "hidden",
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(regToken, lastID)
|
||||||
|
|
||||||
|
// Should NOT see 338 RPL_WHOISACTUALLY.
|
||||||
|
if findNumeric(msgs, "338") {
|
||||||
|
t.Fatalf(
|
||||||
|
"non-oper should not see "+
|
||||||
|
"RPL_WHOISACTUALLY (338), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// But should see 311 RPL_WHOISUSER (normal WHOIS).
|
||||||
|
if !findNumeric(msgs, "311") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_WHOISUSER (311), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhoisShowsOperatorStatus(t *testing.T) {
|
||||||
|
tserver := newTestServerWithOper(t)
|
||||||
|
|
||||||
|
// Create oper user and authenticate.
|
||||||
|
operToken := tserver.createSession("iamoper")
|
||||||
|
_, lastID := tserver.pollMessages(operToken, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(operToken, map[string]any{
|
||||||
|
commandKey: "OPER",
|
||||||
|
bodyKey: []string{testOperName, testOperPassword},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(operToken, lastID)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "381") {
|
||||||
|
t.Fatalf("expected 381, got %v", msgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another user does WHOIS on the oper.
|
||||||
|
queryToken := tserver.createSession("asker")
|
||||||
|
_, queryLastID := tserver.pollMessages(queryToken, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(queryToken, map[string]any{
|
||||||
|
commandKey: "WHOIS",
|
||||||
|
toKey: "iamoper",
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ = tserver.pollMessages(queryToken, queryLastID)
|
||||||
|
|
||||||
|
// Should see 313 RPL_WHOISOPERATOR.
|
||||||
|
if !findNumeric(msgs, "313") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_WHOISOPERATOR (313) in "+
|
||||||
|
"WHOIS of oper, got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperNoOlineConfigured(t *testing.T) {
|
||||||
|
// Standard test server has no oper configured.
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("nooline")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "OPER",
|
||||||
|
bodyKey: []string{testOperName, "password"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Should get 491 since no o-line is configured.
|
||||||
|
if !findNumeric(msgs, "491") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NOOPERHOST (491) when no "+
|
||||||
|
"o-line configured, got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const (
|
|||||||
CmdNames = "NAMES"
|
CmdNames = "NAMES"
|
||||||
CmdNick = "NICK"
|
CmdNick = "NICK"
|
||||||
CmdNotice = "NOTICE"
|
CmdNotice = "NOTICE"
|
||||||
|
CmdOper = "OPER"
|
||||||
CmdPart = "PART"
|
CmdPart = "PART"
|
||||||
CmdPing = "PING"
|
CmdPing = "PING"
|
||||||
CmdPong = "PONG"
|
CmdPong = "PONG"
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ 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
|
||||||
@@ -295,6 +296,7 @@ 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