1 Commits
Author SHA1 Message Date
clawbot a7faabb68f Add the tier 3 utility IRC commands (closes #87)
check / check (push) Canceled after 0s
USERHOST, VERSION, ADMIN, INFO, TIME, KILL and WALLOPS work on the
HTTP API and on the IRC listener. The USERHOST reply, the VERSION and
INFO text, KILL and WALLOPS are each one service function that both
transports call. User mode +w selects who receives WALLOPS. MODE on
your own nick queries or changes your user modes through one parser
on both transports: the whole mode string is checked before anything
is stored, and both flags are stored in one transaction. KILL closes
the victim's IRC connection after sending it KILL and ERROR. The
sessions table gains is_wallops in 001_initial.sql, as the pre-1.0
rule in REPO_POLICIES.md asks.

Model: opus-5-5
2026-10-08 01:13:36 +00:00
15 changed files with 1070 additions and 2545 deletions
+46 -29
View File
@@ -1575,24 +1575,31 @@ reference with all required and optional fields.
**Command dispatch table:** **Command dispatch table:**
| Command | Required Fields | Optional | Response Status | | Command | Required Fields | Optional | Response Status |
| --------- | --------------- | -------- | --------------- | | ---------- | --------------- | -------- | --------------- |
| `PRIVMSG` | `to`, `body` | `meta` | 200 OK | | `PRIVMSG` | `to`, `body` | `meta` | 200 OK |
| `NOTICE` | `to`, `body` | `meta` | 200 OK | | `NOTICE` | `to`, `body` | `meta` | 200 OK |
| `JOIN` | `to` | | 200 OK | | `JOIN` | `to` | | 200 OK |
| `PART` | `to` | `body` | 200 OK | | `PART` | `to` | `body` | 200 OK |
| `NICK` | `body` | | 200 OK | | `NICK` | `body` | | 200 OK |
| `PASS` | `body` | | 200 OK | | `PASS` | `body` | | 200 OK |
| `TOPIC` | `to`, `body` | | 200 OK | | `TOPIC` | `to`, `body` | | 200 OK |
| `MODE` | `to` | | 200 OK | | `MODE` | `to` | `body` | 200 OK |
| `NAMES` | `to` | | 200 OK | | `NAMES` | `to` | | 200 OK |
| `LIST` | | | 200 OK | | `LIST` | | | 200 OK |
| `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 | | `USERHOST` | `body` | | 200 OK |
| `QUIT` | | `body` | 200 OK | | `VERSION` | | | 200 OK |
| `PING` | | | 200 OK | | `ADMIN` | | | 200 OK |
| `INFO` | | | 200 OK |
| `TIME` | | | 200 OK |
| `OPER` | `body` | | 200 OK |
| `KILL` | `body` | | 200 OK |
| `WALLOPS` | `body` | | 200 OK |
| `QUIT` | | `body` | 200 OK |
| `PING` | | | 200 OK |
All IRC commands return HTTP 200 OK. IRC-level success and error responses are All IRC commands return HTTP 200 OK. IRC-level success and error responses are
delivered as **numeric replies** through the message queue (see delivered as **numeric replies** through the message queue (see
@@ -1619,7 +1626,11 @@ 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) |
| 481 | ERR_NOPRIVILEGES | KILL or WALLOPS by a non-operator |
| 483 | ERR_CANTKILLSERVER | KILL of yourself |
| 491 | ERR_NOOPERHOST | Failed OPER authentication | | 491 | ERR_NOOPERHOST | Failed OPER authentication |
| 501 | ERR_UMODEUNKNOWNFLAG | User MODE string not accepted |
| 502 | ERR_USERSDONTMATCH | User MODE for a nick other than your own |
**IRC numeric success replies (delivered via message queue):** **IRC numeric success replies (delivered via message queue):**
@@ -1630,11 +1641,13 @@ auth cookies (401), and server errors (500).
| 003 | RPL_CREATED | Sent on session creation/login | | 003 | RPL_CREATED | Sent on session creation/login |
| 004 | RPL_MYINFO | Sent on session creation/login | | 004 | RPL_MYINFO | Sent on session creation/login |
| 005 | RPL_ISUPPORT | Sent on session creation/login | | 005 | RPL_ISUPPORT | Sent on session creation/login |
| 221 | RPL_UMODEIS | In response to user MODE query | | 221 | RPL_UMODEIS | In response to user MODE |
| 251 | RPL_LUSERCLIENT | On connect or LUSERS command | | 251 | RPL_LUSERCLIENT | On connect or LUSERS command |
| 252 | RPL_LUSEROP | On connect or LUSERS command | | 252 | RPL_LUSEROP | On connect or LUSERS command |
| 254 | RPL_LUSERCHANNELS | On connect or LUSERS command | | 254 | RPL_LUSERCHANNELS | On connect or LUSERS command |
| 255 | RPL_LUSERME | On connect or LUSERS command | | 255 | RPL_LUSERME | On connect or LUSERS command |
| 256–259 | RPL_ADMINME etc. | ADMIN info |
| 302 | RPL_USERHOST | USERHOST reply |
| 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 | | 313 | RPL_WHOISOPERATOR | WHOIS target is oper |
@@ -1642,6 +1655,10 @@ auth cookies (401), and server errors (500).
| 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) | | 338 | RPL_WHOISACTUALLY | WHOIS client IP (oper-only) |
| 351 | RPL_VERSION | VERSION reply |
| 371 | RPL_INFO | INFO line |
| 374 | RPL_ENDOFINFO | End of INFO |
| 391 | RPL_TIME | TIME reply |
| 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 |
@@ -2384,13 +2401,13 @@ IRC_LISTEN_ADDR=
### Supported Commands ### Supported Commands
| Category | Commands | | Category | Commands |
| ---------- | ------------------------------------------------------------------ | | ---------- | ---------------------------------------------------------------------------------------- |
| Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` | | Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` |
| Channels | `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE` | | Channels | `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE` |
| Messaging | `PRIVMSG`, `NOTICE` | | Messaging | `PRIVMSG`, `NOTICE` |
| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY`, `USERHOST`, `VERSION`, `ADMIN`, `INFO`, `TIME` | | Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY`, `USERHOST`, `VERSION`, `ADMIN`, `INFO`, `TIME` |
| Operator | `OPER`, `KILL`, `WALLOPS` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) | | Operator | `OPER`, `KILL`, `WALLOPS` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) |
### Protocol Details ### Protocol Details
@@ -2408,13 +2425,13 @@ IRC_LISTEN_ADDR=
(voice) (voice)
- **User modes**: `+o` (operator, set only via `OPER`), `+w` (receives - **User modes**: `+o` (operator, set only via `OPER`), `+w` (receives
`WALLOPS`). `MODE` for any nick other than your own is rejected with `WALLOPS`). `MODE` for any nick other than your own is rejected with
`ERR_USERSDONTMATCH` (502), for both queries and changes. Nick comparison `ERR_USERSDONTMATCH` (502), for both queries and changes. Nick comparison is
is case-insensitive. case-insensitive.
- **KILL**: an operator's `KILL` broadcasts the victim's `QUIT` to its channel - **KILL**: an operator's `KILL` broadcasts the victim's `QUIT` to its channel
peers, deletes its session, and then sends the victim a `KILL` and peers, deletes its session, and then sends the victim a `KILL` and
`ERROR :Closing Link` before closing its socket. This applies to victims on `ERROR :Closing Link` before closing its socket. This applies to victims on
the IRC listener regardless of whether the `KILL` arrived over IRC or the the IRC listener regardless of whether the `KILL` arrived over IRC or the HTTP
HTTP API. API.
### Bridge to HTTP API ### Bridge to HTTP API
+2 -4
View File
@@ -59,10 +59,8 @@ func (database *Database) Close() error {
return nil return nil
} }
// ExecForTest runs a raw statement against the test // ExecForTest runs a raw statement, such as a trigger that
// database. Tests use it to install SQLite triggers that // makes a write fail.
// force a specific write to fail, so that the atomicity of
// multi-statement helpers can be exercised.
func (database *Database) ExecForTest( func (database *Database) ExecForTest(
ctx context.Context, ctx context.Context,
query string, query string,
+26 -59
View File
@@ -2425,63 +2425,57 @@ func (database *Database) SetChannelUserLimit(
return nil return nil
} }
// SetSessionUserModes applies a set of user-mode flag // SetSessionUserModes sets a session's wallops (+w) and
// changes to a session inside a single transaction, so a // oper (+o) flags in one transaction, so that either both
// multi-mode change such as "+w-o" is all-or-nothing. A nil // changes are stored or neither is. A nil flag is left as
// pointer means the caller did not mention that mode and // it is.
// the stored value must be left untouched.
func (database *Database) SetSessionUserModes( func (database *Database) SetSessionUserModes(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID int64,
wallops *bool, wallops *bool,
oper *bool, oper *bool,
) error { ) error {
if wallops == nil && oper == nil {
return nil
}
transaction, err := database.conn.BeginTx(ctx, nil) transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil { if err != nil {
return fmt.Errorf("begin tx: %w", err) return fmt.Errorf("begin tx: %w", err)
} }
if wallops != nil { if wallops != nil {
if _, err := transaction.ExecContext( _, err = transaction.ExecContext(
ctx, ctx,
`UPDATE sessions SET is_wallops = ? WHERE id = ?`, `UPDATE sessions SET is_wallops = ? WHERE id = ?`,
boolToInt(*wallops), sessionID, boolToInt(*wallops), sessionID,
); err != nil { )
if err != nil {
_ = transaction.Rollback() _ = transaction.Rollback()
return fmt.Errorf( return fmt.Errorf("set session wallops: %w", err)
"set session wallops: %w", err,
)
} }
} }
if oper != nil { if oper != nil {
if _, err := transaction.ExecContext( _, err = transaction.ExecContext(
ctx, ctx,
`UPDATE sessions SET is_oper = ? WHERE id = ?`, `UPDATE sessions SET is_oper = ? WHERE id = ?`,
boolToInt(*oper), sessionID, boolToInt(*oper), sessionID,
); err != nil { )
if err != nil {
_ = transaction.Rollback() _ = transaction.Rollback()
return fmt.Errorf("set session oper: %w", err) return fmt.Errorf("set session oper: %w", err)
} }
} }
if err := transaction.Commit(); err != nil { err = transaction.Commit()
_ = transaction.Rollback() if err != nil {
return fmt.Errorf("commit user modes: %w", err) return fmt.Errorf("commit user modes: %w", err)
} }
return nil return nil
} }
// boolToInt renders a Go bool as the 0/1 integer used for // boolToInt returns the 0 or 1 that the schema stores for a
// boolean columns in the SQLite schema. // boolean.
func boolToInt(value bool) int { func boolToInt(value bool) int {
if value { if value {
return 1 return 1
@@ -2490,30 +2484,6 @@ func boolToInt(value bool) int {
return 0 return 0
} }
// SetSessionWallops sets the wallops (+w) flag on a
// session.
func (database *Database) SetSessionWallops(
ctx context.Context,
sessionID int64,
enabled bool,
) error {
val := 0
if enabled {
val = 1
}
_, err := database.conn.ExecContext(
ctx,
`UPDATE sessions SET is_wallops = ? WHERE id = ?`,
val, sessionID,
)
if err != nil {
return fmt.Errorf("set session wallops: %w", err)
}
return nil
}
// IsSessionWallops returns whether the session has the // IsSessionWallops returns whether the session has the
// wallops (+w) usermode set. // wallops (+w) usermode set.
func (database *Database) IsSessionWallops( func (database *Database) IsSessionWallops(
@@ -2557,16 +2527,19 @@ func (database *Database) GetWallopsSessionIDs(
for rows.Next() { for rows.Next() {
var sessionID int64 var sessionID int64
if scanErr := rows.Scan(&sessionID); scanErr != nil {
err = rows.Scan(&sessionID)
if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"scan wallops session: %w", scanErr, "scan wallops session: %w", err,
) )
} }
ids = append(ids, sessionID) ids = append(ids, sessionID)
} }
if err := rows.Err(); err != nil { err = rows.Err()
if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"iterate wallops sessions: %w", err, "iterate wallops sessions: %w", err,
) )
@@ -2585,17 +2558,11 @@ type UserhostInfo struct {
} }
// GetUserhostInfo returns USERHOST info for the given // GetUserhostInfo returns USERHOST info for the given
// nicks. Nicks with no session are omitted from the // nicks. Nicks with no session are left out.
// result; any other database failure is returned, because
// an unreadable row is not the same as an absent one.
func (database *Database) GetUserhostInfo( func (database *Database) GetUserhostInfo(
ctx context.Context, ctx context.Context,
nicks []string, nicks []string,
) ([]UserhostInfo, error) { ) ([]UserhostInfo, error) {
if len(nicks) == 0 {
return nil, nil
}
results := make([]UserhostInfo, 0, len(nicks)) results := make([]UserhostInfo, 0, len(nicks))
for _, nick := range nicks { for _, nick := range nicks {
@@ -2611,11 +2578,11 @@ func (database *Database) GetUserhostInfo(
&info.Nick, &info.Username, &info.Hostname, &info.Nick, &info.Username, &info.Hostname,
&info.IsOper, &info.AwayMessage, &info.IsOper, &info.AwayMessage,
) )
if err != nil { if errors.Is(err, sql.ErrNoRows) {
if errors.Is(err, sql.ErrNoRows) { continue
continue // nick not online }
}
if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"userhost lookup %q: %w", nick, err, "userhost lookup %q: %w", nick, err,
) )
+15 -23
View File
@@ -1492,13 +1492,9 @@ func TestChannelUserLimit(t *testing.T) {
} }
} }
// TestSetSessionUserModesIsAtomic proves that a multi-mode // TestSetSessionUserModesIsAtomic makes the is_oper write
// change is all-or-nothing. A trigger makes the is_oper // fail after the is_wallops write has run, and checks that
// UPDATE fail after the is_wallops UPDATE has already run, // the wallops change was rolled back.
// which is exactly the "+w-o" partial-failure the previous
// implementation exhibited: it issued the two UPDATEs
// independently, so +w persisted while the caller reported
// total failure.
func TestSetSessionUserModesIsAtomic(t *testing.T) { func TestSetSessionUserModesIsAtomic(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1540,16 +1536,12 @@ func TestSetSessionUserModesIsAtomic(t *testing.T) {
} }
if gotWallops { if gotWallops {
t.Error( t.Error("wallops was stored although the change failed")
"wallops persisted despite the transaction " +
"failing; the apply stage is not atomic",
)
} }
} }
// TestSetSessionUserModesAppliesBoth is the success-path // TestSetSessionUserModesAppliesBoth checks that both flags
// counterpart: when nothing fails, both flags are written, // are written, and that a nil flag is left as it is.
// and a nil pointer leaves that flag untouched.
func TestSetSessionUserModesAppliesBoth(t *testing.T) { func TestSetSessionUserModesAppliesBoth(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1563,18 +1555,18 @@ func TestSetSessionUserModesAppliesBoth(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if err := database.SetSessionOper( err = database.SetSessionOper(ctx, sessionID, true)
ctx, sessionID, true, if err != nil {
); err != nil {
t.Fatal(err) t.Fatal(err)
} }
wallops := true wallops := true
oper := false oper := false
if err := database.SetSessionUserModes( err = database.SetSessionUserModes(
ctx, sessionID, &wallops, &oper, ctx, sessionID, &wallops, &oper,
); err != nil { )
if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -1597,10 +1589,10 @@ func TestSetSessionUserModesAppliesBoth(t *testing.T) {
) )
} }
// A nil pointer must leave the stored value alone. err = database.SetSessionUserModes(
if err := database.SetSessionUserModes(
ctx, sessionID, nil, nil, ctx, sessionID, nil, nil,
); err != nil { )
if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -1612,6 +1604,6 @@ func TestSetSessionUserModesAppliesBoth(t *testing.T) {
} }
if !gotWallops { if !gotWallops {
t.Error("nil pointers must not clear wallops") t.Error("a nil wallops flag cleared wallops")
} }
} }
+37 -40
View File
@@ -962,12 +962,10 @@ func (hdlr *Handlers) dispatchCommand(
bodyLines func() []string, bodyLines func() []string,
) { ) {
switch command { switch command {
case irc.CmdAway, irc.CmdNick, case irc.CmdAway:
irc.CmdPass, irc.CmdInvite: hdlr.handleAway(
hdlr.dispatchBodyOnlyCommand(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick, bodyLines,
command, bodyLines,
) )
case irc.CmdPrivmsg, irc.CmdNotice: case irc.CmdPrivmsg, irc.CmdNotice:
hdlr.handlePrivmsg( hdlr.handlePrivmsg(
@@ -986,12 +984,27 @@ func (hdlr *Handlers) dispatchCommand(
writer, request, writer, request,
sessionID, clientID, nick, target, body, sessionID, clientID, nick, target, body,
) )
case irc.CmdNick:
hdlr.handleNick(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdPass:
hdlr.handlePass(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdTopic: case irc.CmdTopic:
hdlr.handleTopic( hdlr.handleTopic(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
target, body, bodyLines, target, body, bodyLines,
) )
case irc.CmdInvite:
hdlr.handleInvite(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdKick: case irc.CmdKick:
hdlr.handleKick( hdlr.handleKick(
writer, request, writer, request,
@@ -1005,12 +1018,9 @@ func (hdlr *Handlers) dispatchCommand(
case irc.CmdOper, irc.CmdKill, irc.CmdWallops: case irc.CmdOper, irc.CmdKill, irc.CmdWallops:
hdlr.dispatchOperCommand( hdlr.dispatchOperCommand(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick, command, bodyLines,
command, bodyLines,
) )
case irc.CmdMotd, irc.CmdPing, case irc.CmdMotd, irc.CmdPing:
irc.CmdVersion, irc.CmdAdmin,
irc.CmdInfo, irc.CmdTime:
hdlr.dispatchInfoCommand( hdlr.dispatchInfoCommand(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
@@ -1070,6 +1080,22 @@ func (hdlr *Handlers) dispatchQueryCommand(
writer, request, writer, request,
sessionID, clientID, nick, bodyLines, sessionID, clientID, nick, bodyLines,
) )
case irc.CmdVersion:
hdlr.handleVersion(
writer, request, sessionID, clientID, nick,
)
case irc.CmdAdmin:
hdlr.handleAdmin(
writer, request, sessionID, clientID, nick,
)
case irc.CmdInfo:
hdlr.handleInfo(
writer, request, sessionID, clientID, nick,
)
case irc.CmdTime:
hdlr.handleTime(
writer, request, sessionID, clientID, nick,
)
default: default:
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
request.Context(), clientID, request.Context(), clientID,
@@ -1863,8 +1889,7 @@ func (hdlr *Handlers) deliverSetTopicNumerics(
} }
// dispatchInfoCommand handles informational IRC commands // dispatchInfoCommand handles informational IRC commands
// that produce server-side numerics (MOTD, PING, // that produce server-side numerics (MOTD, PING).
// VERSION, ADMIN, INFO, TIME).
func (hdlr *Handlers) dispatchInfoCommand( func (hdlr *Handlers) dispatchInfoCommand(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -1890,34 +1915,6 @@ func (hdlr *Handlers) dispatchInfoCommand(
}, },
http.StatusOK) http.StatusOK)
return
case irc.CmdVersion:
hdlr.handleVersion(
writer, request,
sessionID, clientID, nick,
)
return
case irc.CmdAdmin:
hdlr.handleAdmin(
writer, request,
sessionID, clientID, nick,
)
return
case irc.CmdInfo:
hdlr.handleInfo(
writer, request,
sessionID, clientID, nick,
)
return
case irc.CmdTime:
hdlr.handleTime(
writer, request,
sessionID, clientID, nick,
)
return return
} }
+70 -339
View File
@@ -1,56 +1,15 @@
package handlers package handlers
import ( import (
"encoding/json"
"errors"
"net/http" "net/http"
"strings" "strings"
"time" "time"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc" "sneak.berlin/go/neoirc/pkg/irc"
) )
// maxUserhostNicks is the maximum number of nicks allowed // dispatchOperCommand routes the server operator commands
// in a single USERHOST query (RFC 2812). // OPER, KILL and WALLOPS to their handlers.
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( func (hdlr *Handlers) dispatchOperCommand(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -77,8 +36,8 @@ func (hdlr *Handlers) dispatchOperCommand(
} }
} }
// handleUserhost handles the USERHOST command. // handleUserhost handles USERHOST: user@host for each of
// Returns user@host info for up to 5 nicks. // up to five nicks given in the body.
func (hdlr *Handlers) handleUserhost( func (hdlr *Handlers) handleUserhost(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -86,8 +45,6 @@ func (hdlr *Handlers) handleUserhost(
nick string, nick string,
bodyLines func() []string, bodyLines func() []string,
) { ) {
ctx := request.Context()
lines := bodyLines() lines := bodyLines()
if len(lines) == 0 { if len(lines) == 0 {
hdlr.respondIRCError( hdlr.respondIRCError(
@@ -100,107 +57,47 @@ func (hdlr *Handlers) handleUserhost(
return return
} }
// Limit to 5 nicks per RFC 2812. reply, err := hdlr.svc.UserhostReply(
nicks := lines request.Context(), lines, hdlr.serverName(),
if len(nicks) > maxUserhostNicks {
nicks = nicks[:maxUserhostNicks]
}
infos, err := hdlr.params.Database.GetUserhostInfo(
ctx, nicks,
) )
if err != nil { if hdlr.handleServiceError(
hdlr.log.Error( writer, request, clientID, sessionID, nick, err,
"userhost query failed", "error", err, ) {
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return return
} }
replyStr := hdlr.buildUserhostReply(infos)
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplUserHost, nick, nil, request.Context(), clientID,
replyStr, irc.RplUserHost, nick, nil, reply,
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"}, map[string]string{statusKey: "ok"},
http.StatusOK) http.StatusOK)
} }
// buildUserhostReply builds the RPL_USERHOST reply // handleVersion handles VERSION.
// 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( func (hdlr *Handlers) handleVersion(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID, clientID int64, sessionID, clientID int64,
nick string, nick string,
) { ) {
ctx := request.Context()
srvName := hdlr.serverName()
version := hdlr.svc.ServerVersion()
// 351 RPL_VERSION
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplVersion, nick, request.Context(), clientID, irc.RplVersion, nick,
[]string{version + ".", srvName}, []string{
hdlr.svc.ServerVersion() + ".",
hdlr.serverName(),
},
"", "",
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"}, map[string]string{statusKey: "ok"},
http.StatusOK) http.StatusOK)
} }
// handleAdmin handles the ADMIN command. // handleAdmin handles ADMIN.
// Returns server admin contact info.
func (hdlr *Handlers) handleAdmin( func (hdlr *Handlers) handleAdmin(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -210,39 +107,29 @@ func (hdlr *Handlers) handleAdmin(
ctx := request.Context() ctx := request.Context()
srvName := hdlr.serverName() srvName := hdlr.serverName()
// 256 RPL_ADMINME
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminMe, nick, ctx, clientID, irc.RplAdminMe, nick,
[]string{srvName}, []string{srvName}, "Administrative info",
"Administrative info",
) )
// 257 RPL_ADMINLOC1
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc1, nick, nil, ctx, clientID, irc.RplAdminLoc1, nick, nil,
"neoirc server", "neoirc server",
) )
// 258 RPL_ADMINLOC2
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc2, nick, nil, ctx, clientID, irc.RplAdminLoc2, nick, nil,
"IRC over HTTP", "IRC over HTTP",
) )
// 259 RPL_ADMINEMAIL
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminEmail, nick, nil, ctx, clientID, irc.RplAdminEmail, nick, nil,
"admin@"+srvName, "admin@"+srvName,
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"}, map[string]string{statusKey: "ok"},
http.StatusOK) http.StatusOK)
} }
// handleInfo handles the INFO command. // handleInfo handles INFO.
// Returns server software information.
func (hdlr *Handlers) handleInfo( func (hdlr *Handlers) handleInfo(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -252,51 +139,41 @@ func (hdlr *Handlers) handleInfo(
ctx := request.Context() ctx := request.Context()
for _, line := range hdlr.svc.InfoLines() { for _, line := range hdlr.svc.InfoLines() {
// 371 RPL_INFO
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplInfo, nick, nil, ctx, clientID, irc.RplInfo, nick, nil, line,
line,
) )
} }
// 374 RPL_ENDOFINFO
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfInfo, nick, nil, ctx, clientID, irc.RplEndOfInfo, nick, nil,
"End of /INFO list", "End of /INFO list",
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"}, map[string]string{statusKey: "ok"},
http.StatusOK) http.StatusOK)
} }
// handleTime handles the TIME command. // handleTime handles TIME: the server's local time.
// Returns the server's local time in RFC format.
func (hdlr *Handlers) handleTime( func (hdlr *Handlers) handleTime(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID, clientID int64, sessionID, clientID int64,
nick string, nick string,
) { ) {
ctx := request.Context()
srvName := hdlr.serverName()
// 391 RPL_TIME
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplTime, nick, request.Context(), clientID, irc.RplTime, nick,
[]string{srvName}, []string{hdlr.serverName()},
time.Now().Format(time.RFC1123), time.Now().Format(time.RFC1123),
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"}, map[string]string{statusKey: "ok"},
http.StatusOK) http.StatusOK)
} }
// handleKill handles the KILL command. // handleKill handles KILL: the body is the target nick and
// Forcibly disconnects a user (oper only). // an optional reason.
func (hdlr *Handlers) handleKill( func (hdlr *Handlers) handleKill(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -304,25 +181,9 @@ func (hdlr *Handlers) handleKill(
nick string, nick string,
bodyLines func() []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() lines := bodyLines()
var targetNick string targetNick := ""
if len(lines) > 0 { if len(lines) > 0 {
targetNick = strings.TrimSpace(lines[0]) targetNick = strings.TrimSpace(lines[0])
} }
@@ -343,47 +204,22 @@ func (hdlr *Handlers) handleKill(
reason = lines[1] reason = lines[1]
} }
targetSID, lookupErr := hdlr.params.Database. err := hdlr.svc.KillUser(
GetSessionByNick(ctx, targetNick) request.Context(), sessionID, nick,
if lookupErr != nil { targetNick, reason,
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
}
quitReason := "Killed (" + nick + " (" + reason + "))"
// KillSession broadcasts the QUIT, deletes the session
// and disconnects the victim's wire connection if it
// holds one.
hdlr.svc.KillSession(
ctx, targetSID, targetNick, quitReason,
) )
if hdlr.handleServiceError(
writer, request, clientID, sessionID, nick, err,
) {
return
}
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"}, map[string]string{statusKey: "ok"},
http.StatusOK) http.StatusOK)
} }
// handleWallops handles the WALLOPS command. // handleWallops handles WALLOPS: the body is the message.
// Broadcasts a message to all users with +w usermode
// (oper only).
func (hdlr *Handlers) handleWallops( func (hdlr *Handlers) handleWallops(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -391,22 +227,6 @@ func (hdlr *Handlers) handleWallops(
nick string, nick string,
bodyLines func() []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() lines := bodyLines()
if len(lines) == 0 { if len(lines) == 0 {
hdlr.respondIRCError( hdlr.respondIRCError(
@@ -419,54 +239,25 @@ func (hdlr *Handlers) handleWallops(
return return
} }
message := strings.Join(lines, " ") err := hdlr.svc.SendWallops(
request.Context(), sessionID, nick,
wallopsSIDs, err := hdlr.params.Database. strings.Join(lines, " "),
GetWallopsSessionIDs(ctx) )
if err != nil { if hdlr.handleServiceError(
hdlr.log.Error( writer, request, clientID, sessionID, nick, err,
"get wallops sessions failed", "error", err, ) {
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return 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, hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"}, map[string]string{statusKey: "ok"},
http.StatusOK) http.StatusOK)
} }
// handleUserMode handles user mode queries and changes // handleUserMode handles MODE for a nick: a query when the
// (e.g., MODE nick, MODE nick +w). Delegates to the // body is empty, otherwise a change by the mode string in
// shared service.ApplyUserMode / service.QueryUserMode so // the body. Only the caller's own nick, in any letter case,
// that mode string processing is identical for both the // is allowed.
// HTTP API and IRC wire protocol.
func (hdlr *Handlers) handleUserMode( func (hdlr *Handlers) handleUserMode(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -474,14 +265,7 @@ func (hdlr *Handlers) handleUserMode(
nick, target string, nick, target string,
bodyLines func() []string, bodyLines func() []string,
) { ) {
ctx := request.Context() if !strings.EqualFold(target, nick) {
// Users can only query or change their own modes. The
// check is above the query/change split so that both
// forms are rejected, and uses EqualFold because IRC
// nicks are case-insensitive — matching the wire path
// in ircserver.handleUserMode.
if target != "" && !strings.EqualFold(target, nick) {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, clientID, sessionID, writer, request, clientID, sessionID,
irc.ErrUsersDoNotMatch, nick, nil, irc.ErrUsersDoNotMatch, nick, nil,
@@ -491,86 +275,33 @@ func (hdlr *Handlers) handleUserMode(
return return
} }
lines := bodyLines()
// Mode change requested.
if len(lines) > 0 {
hdlr.changeUserMode(
writer, request,
sessionID, clientID, nick, lines[0],
)
return
}
// Mode query — delegate to shared service.
modeStr, err := hdlr.svc.QueryUserMode(ctx, sessionID)
if err != nil {
hdlr.log.Error(
"query user mode failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUmodeIs, nick, nil,
modeStr,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// changeUserMode applies a mode string to the caller's own
// session. The caller has already verified that the target
// nick is the caller's own.
func (hdlr *Handlers) changeUserMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, modeStr string,
) {
ctx := request.Context() ctx := request.Context()
newModes, err := hdlr.svc.ApplyUserMode( var (
ctx, sessionID, modeStr, modes string
err error
) )
if err != nil {
var ircErr *service.IRCError
if errors.As(err, &ircErr) {
hdlr.respondIRCError(
writer, request,
clientID, sessionID,
ircErr.Code, nick, ircErr.Params,
ircErr.Message,
)
return lines := bodyLines()
} if len(lines) > 0 {
modes, err = hdlr.svc.ApplyUserMode(
hdlr.respondError( ctx, sessionID, lines[0],
writer, request,
"internal error",
http.StatusInternalServerError,
) )
} else {
modes, err = hdlr.svc.QueryUserMode(ctx, sessionID)
}
if hdlr.handleServiceError(
writer, request, clientID, sessionID, nick, err,
) {
return return
} }
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplUmodeIs, nick, nil, ctx, clientID, irc.RplUmodeIs, nick, nil, modes,
newModes,
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"}, map[string]string{statusKey: "ok"},
http.StatusOK) http.StatusOK)
} }
File diff suppressed because it is too large Load Diff
+63 -191
View File
@@ -13,7 +13,7 @@ import (
) )
// sendIRCError maps a service.IRCError to an IRC numeric // sendIRCError maps a service.IRCError to an IRC numeric
// reply on the wire. // reply on the wire, and logs any other error.
func (c *Conn) sendIRCError(err error) { func (c *Conn) sendIRCError(err error) {
var ircErr *service.IRCError var ircErr *service.IRCError
if errors.As(err, &ircErr) { if errors.As(err, &ircErr) {
@@ -21,7 +21,11 @@ func (c *Conn) sendIRCError(err error) {
args = append(args, ircErr.Params...) args = append(args, ircErr.Params...)
args = append(args, ircErr.Message) args = append(args, ircErr.Message)
c.sendNumeric(ircErr.Code, args...) c.sendNumeric(ircErr.Code, args...)
return
} }
c.log.Error("command failed", "error", err)
} }
// handleCAP silently acknowledges CAP negotiation. // handleCAP silently acknowledges CAP negotiation.
@@ -689,14 +693,14 @@ func (c *Conn) applyChannelModes(
} }
} }
// handleUserMode handles MODE for users. // handleUserMode handles MODE for a nick: a query without a
// mode string, otherwise a change. Only the client's own
// nick, in any letter case, is allowed.
func (c *Conn) handleUserMode( func (c *Conn) handleUserMode(
ctx context.Context, ctx context.Context,
msg *Message, msg *Message,
) { ) {
target := msg.Params[0] if !strings.EqualFold(msg.Params[0], c.currentNick()) {
if !strings.EqualFold(target, c.currentNick()) {
c.sendNumeric( c.sendNumeric(
irc.ErrUsersDoNotMatch, irc.ErrUsersDoNotMatch,
"Can't change mode for other users", "Can't change mode for other users",
@@ -705,48 +709,26 @@ func (c *Conn) handleUserMode(
return return
} }
// Mode query (no mode string). var (
if len(msg.Params) < 2 { //nolint:mnd modes string
modes, err := c.svc.QueryUserMode( err error
ctx, c.sessionID,
)
if err != nil {
c.log.Error(
"query user mode failed", "error", err,
)
c.sendNumeric(
irc.ErrUmodeUnknownFlag,
"Unable to read user modes",
)
return
}
c.sendNumeric(irc.RplUmodeIs, modes)
return
}
newModes, err := c.svc.ApplyUserMode(
ctx, c.sessionID, msg.Params[1],
) )
if err != nil {
var ircErr *service.IRCError
if errors.As(err, &ircErr) {
c.sendNumeric(ircErr.Code, ircErr.Message)
return if len(msg.Params) == 1 {
} modes, err = c.svc.QueryUserMode(ctx, c.sessionID)
} else {
c.sendNumeric( modes, err = c.svc.ApplyUserMode(
irc.ErrUmodeUnknownFlag, ctx, c.sessionID, msg.Params[1],
"Unknown MODE flag",
) )
}
if err != nil {
c.sendIRCError(err)
return return
} }
c.sendNumeric(irc.RplUmodeIs, newModes) c.sendNumeric(irc.RplUmodeIs, modes)
} }
// handleNames replies with channel member list. // handleNames replies with channel member list.
@@ -1294,224 +1276,114 @@ func (c *Conn) handleInvite(
) )
} }
// handleUserhost replies with USERHOST info. // handleUserhost replies with user@host for each of up to
// five nicks.
func (c *Conn) handleUserhost( func (c *Conn) handleUserhost(
ctx context.Context, ctx context.Context,
msg *Message, msg *Message,
) { ) {
if len(msg.Params) < 1 { if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
irc.CmdUserhost, "Not enough parameters",
)
return return
} }
replies := make([]string, 0, len(msg.Params)) reply, err := c.svc.UserhostReply(
ctx, msg.Params, c.serverSfx,
)
if err != nil {
c.sendIRCError(err)
for _, nick := range msg.Params { return
sid, err := c.database.GetSessionByNick(ctx, nick)
if err != nil {
continue
}
hostInfo, _ := c.database.GetSessionHostInfo(
ctx, sid,
)
host := "*"
if hostInfo != nil {
host = hostInfo.Hostname
}
isOper, _ := c.database.IsSessionOper(ctx, sid)
operStar := ""
if isOper {
operStar = "*"
}
replies = append(
replies,
nick+operStar+"=+"+nick+"@"+host,
)
} }
c.sendNumeric( c.sendNumeric(irc.RplUserHost, reply)
irc.RplUserHost,
strings.Join(replies, " "),
)
} }
// handleVersion replies with the server version string. // handleVersion replies with the server version.
func (c *Conn) handleVersion() { func (c *Conn) handleVersion() {
c.sendNumeric( c.sendNumeric(
irc.RplVersion, irc.RplVersion,
c.svc.ServerVersion()+".", c.serverSfx, c.svc.ServerVersion()+".", c.serverSfx, "",
"",
) )
} }
// handleAdmin replies with server admin info. // handleAdmin replies with the server's admin info.
func (c *Conn) handleAdmin() { func (c *Conn) handleAdmin() {
srvName := c.serverSfx
c.sendNumeric( c.sendNumeric(
irc.RplAdminMe, irc.RplAdminMe, c.serverSfx, "Administrative info",
srvName, "Administrative info",
)
c.sendNumeric(
irc.RplAdminLoc1,
"neoirc server",
)
c.sendNumeric(
irc.RplAdminLoc2,
"IRC over HTTP",
)
c.sendNumeric(
irc.RplAdminEmail,
"admin@"+srvName,
) )
c.sendNumeric(irc.RplAdminLoc1, "neoirc server")
c.sendNumeric(irc.RplAdminLoc2, "IRC over HTTP")
c.sendNumeric(irc.RplAdminEmail, "admin@"+c.serverSfx)
} }
// handleInfo replies with server software info. // handleInfo replies with the server's software info.
func (c *Conn) handleInfo() { func (c *Conn) handleInfo() {
for _, line := range c.svc.InfoLines() { for _, line := range c.svc.InfoLines() {
c.sendNumeric(irc.RplInfo, line) c.sendNumeric(irc.RplInfo, line)
} }
c.sendNumeric( c.sendNumeric(irc.RplEndOfInfo, "End of /INFO list")
irc.RplEndOfInfo,
"End of /INFO list",
)
} }
// handleTime replies with the server's current time. // handleTime replies with the server's local time.
func (c *Conn) handleTime() { func (c *Conn) handleTime() {
srvName := c.serverSfx
c.sendNumeric( c.sendNumeric(
irc.RplTime, irc.RplTime,
srvName, time.Now().Format(time.RFC1123), c.serverSfx, time.Now().Format(time.RFC1123),
) )
} }
// handleKillCmd forcibly disconnects a target user (oper // handleKill handles KILL <nick> [<reason>].
// only). func (c *Conn) handleKill(
func (c *Conn) handleKillCmd(
ctx context.Context, ctx context.Context,
msg *Message, msg *Message,
) { ) {
isOper, err := c.database.IsSessionOper(
ctx, c.sessionID,
)
if err != nil || !isOper {
c.sendNumeric(
irc.ErrNoPrivileges,
"Permission Denied- "+
"You're not an IRC operator",
)
return
}
if len(msg.Params) < 1 { if len(msg.Params) < 1 {
c.sendNumeric( c.sendNumeric(
irc.ErrNeedMoreParams, irc.ErrNeedMoreParams,
"KILL", "Not enough parameters", irc.CmdKill, "Not enough parameters",
) )
return return
} }
targetNick := msg.Params[0]
reason := "KILLed" reason := "KILLed"
if len(msg.Params) > 1 { if len(msg.Params) > 1 {
reason = msg.Params[1] reason = msg.Params[1]
} }
killerNick := c.currentNick() err := c.svc.KillUser(
ctx, c.sessionID, c.currentNick(),
if strings.EqualFold(targetNick, killerNick) { msg.Params[0], reason,
c.sendNumeric(
irc.ErrCantKillServer,
"You cannot KILL yourself",
)
return
}
targetSID, lookupErr := c.database.GetSessionByNick(
ctx, targetNick,
) )
if lookupErr != nil { if err != nil {
c.sendNumeric( c.sendIRCError(err)
irc.ErrNoSuchNick,
targetNick, "No such nick/channel",
)
return
} }
quitReason := "Killed (" + killerNick +
" (" + reason + "))"
// KillSession broadcasts the QUIT, deletes the session
// and disconnects the victim's wire connection.
c.svc.KillSession(
ctx, targetSID, targetNick, quitReason,
)
} }
// handleWallopsCmd broadcasts to all +w users (oper only). // handleWallops handles WALLOPS <message>.
func (c *Conn) handleWallopsCmd( func (c *Conn) handleWallops(
ctx context.Context, ctx context.Context,
msg *Message, msg *Message,
) { ) {
isOper, err := c.database.IsSessionOper(
ctx, c.sessionID,
)
if err != nil || !isOper {
c.sendNumeric(
irc.ErrNoPrivileges,
"Permission Denied- "+
"You're not an IRC operator",
)
return
}
if len(msg.Params) < 1 { if len(msg.Params) < 1 {
c.sendNumeric( c.sendNumeric(
irc.ErrNeedMoreParams, irc.ErrNeedMoreParams,
"WALLOPS", "Not enough parameters", irc.CmdWallops, "Not enough parameters",
) )
return return
} }
message := msg.Params[0] err := c.svc.SendWallops(
ctx, c.sessionID, c.currentNick(), msg.Params[0],
wallopsSIDs, wallErr := c.database. )
GetWallopsSessionIDs(ctx) if err != nil {
if wallErr != nil { c.sendIRCError(err)
c.log.Error(
"get wallops sessions failed",
"error", wallErr,
)
return
}
if len(wallopsSIDs) > 0 {
body, mErr := json.Marshal([]string{message})
if mErr != nil {
return
}
_, _, _ = c.svc.FanOut(
ctx, irc.CmdWallops, c.currentNick(), "*",
nil, body, nil, wallopsSIDs,
)
} }
} }
+19 -26
View File
@@ -47,10 +47,9 @@ type Conn struct {
serverSfx string serverSfx string
commands map[string]cmdHandler commands map[string]cmdHandler
// writeMu serializes writes to conn. A connection is // writeMu serializes writes to conn, which come from
// written to by its own read loop, by its relay // serve(), from the relay goroutine and, for KILL, from
// goroutine, and — when an operator KILLs it — by // Disconnect.
// another client's goroutine.
writeMu sync.Mutex writeMu sync.Mutex
mu sync.Mutex mu sync.Mutex
@@ -141,7 +140,7 @@ func (c *Conn) Disconnect(reason string) {
func (c *Conn) notifyKilledAndClose( func (c *Conn) notifyKilledAndClose(
nick, host, reason string, nick, host, reason string,
) { ) {
defer c.conn.Close() //nolint:errcheck defer func() { _ = c.conn.Close() }()
c.sendWithin( c.sendWithin(
killWriteWindow, killWriteWindow,
@@ -188,13 +187,13 @@ func (c *Conn) buildCommandMap() map[string]cmdHandler {
"CAP": func(_ context.Context, msg *Message) { "CAP": func(_ context.Context, msg *Message) {
c.handleCAP(msg) c.handleCAP(msg)
}, },
"USERHOST": c.handleUserhost, irc.CmdUserhost: c.handleUserhost,
irc.CmdVersion: func(context.Context, *Message) { c.handleVersion() }, irc.CmdVersion: func(context.Context, *Message) { c.handleVersion() },
irc.CmdAdmin: func(context.Context, *Message) { c.handleAdmin() }, irc.CmdAdmin: func(context.Context, *Message) { c.handleAdmin() },
irc.CmdInfo: func(context.Context, *Message) { c.handleInfo() }, irc.CmdInfo: func(context.Context, *Message) { c.handleInfo() },
irc.CmdTime: func(context.Context, *Message) { c.handleTime() }, irc.CmdTime: func(context.Context, *Message) { c.handleTime() },
irc.CmdKill: c.handleKillCmd, irc.CmdKill: c.handleKill,
irc.CmdWallops: c.handleWallopsCmd, irc.CmdWallops: c.handleWallops,
} }
} }
@@ -263,9 +262,8 @@ func (c *Conn) cleanup(ctx context.Context) {
if wasRegistered && sessID > 0 { if wasRegistered && sessID > 0 {
c.svc.UnregisterWireConn(sessID, c) c.svc.UnregisterWireConn(sessID, c)
// A KILLed session has already been broadcast and // KILL has already sent the QUIT and deleted the
// deleted by the killer; broadcasting again would // session.
// fan out a QUIT for a session row that is gone.
if !wasKilled { if !wasKilled {
c.svc.BroadcastQuit( c.svc.BroadcastQuit(
ctx, sessID, nick, "Connection closed", ctx, sessID, nick, "Connection closed",
@@ -276,8 +274,8 @@ func (c *Conn) cleanup(ctx context.Context) {
c.conn.Close() //nolint:errcheck,gosec c.conn.Close() //nolint:errcheck,gosec
} }
// isClosed reports whether the connection has been marked // isClosed reports whether QUIT or KILL has ended the
// for teardown, either by QUIT or by an operator KILL. // connection.
func (c *Conn) isClosed() bool { func (c *Conn) isClosed() bool {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -285,9 +283,7 @@ func (c *Conn) isClosed() bool {
return c.closed return c.closed
} }
// currentNick returns the connection's registered nick. // currentNick returns c.nick, read under c.mu.
// c.nick is written under c.mu during registration and
// NICK changes, so every read must take the mutex.
func (c *Conn) currentNick() string { func (c *Conn) currentNick() string {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -300,10 +296,8 @@ func (c *Conn) send(line string) {
c.sendWithin(writeTimeout, line) c.sendWithin(writeTimeout, line)
} }
// sendWithin writes a formatted IRC line to the connection // sendWithin writes a formatted IRC line to the connection,
// under the given write deadline. Callers that must not be // giving up after timeout.
// held hostage by an unresponsive peer pass a shorter window
// than writeTimeout.
func (c *Conn) sendWithin( func (c *Conn) sendWithin(
timeout time.Duration, timeout time.Duration,
line string, line string,
@@ -507,8 +501,7 @@ func (c *Conn) completeRegistration(ctx context.Context) {
c.registered = true c.registered = true
c.mu.Unlock() c.mu.Unlock()
// Make this connection reachable by session ID so that // So that KILL, from either transport, can close it.
// KILL from either transport can disconnect it.
c.svc.RegisterWireConn(sessionID, c) c.svc.RegisterWireConn(sessionID, c)
// If PASS was provided before registration, set the // If PASS was provided before registration, set the
+41 -69
View File
@@ -13,43 +13,37 @@ import (
"sneak.berlin/go/neoirc/internal/ircserver" "sneak.berlin/go/neoirc/internal/ircserver"
) )
// disconnectBudget is how long Disconnect is allowed to // newKillVictim returns a Conn for the nick victim whose
// take when the victim never reads. It is far below the // socket is one end of a net.Pipe, and the other end, from
// 30s writeTimeout that the old synchronous implementation // which the test reads what the victim is sent. A write to
// would have burned on each of its two writes. // the pipe blocks until the other end reads.
const disconnectBudget = 2 * time.Second func newKillVictim(t *testing.T) (*ircserver.Conn, net.Conn) {
t.Helper()
// TestDisconnectDoesNotBlockOnUnresponsiveVictim proves serverSide, clientSide := net.Pipe()
// that an operator KILL cannot be stalled by its target.
// The victim's socket is a net.Pipe, so every write blocks t.Cleanup(func() { _ = clientSide.Close() })
// until the peer reads and the peer here never does. The
// old implementation performed both notification writes on log := slog.New(slog.NewTextHandler(
// the killer's goroutine, which wedged the operator's own os.Stderr,
// serve() loop on the IRC path and the API request on the &slog.HandlerOptions{Level: slog.LevelError},
// HTTP path for as long as the victim cared to stay silent. ))
cfg := &config.Config{ServerName: testServerName}
return ircserver.NewTestConn(
log, cfg, serverSide, "victim",
), clientSide
}
// TestDisconnectDoesNotBlockOnUnresponsiveVictim checks
// that Disconnect returns although the victim never reads,
// so a KILL cannot stall the operator who sent it.
func TestDisconnectDoesNotBlockOnUnresponsiveVictim( func TestDisconnectDoesNotBlockOnUnresponsiveVictim(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
serverSide, clientSide := net.Pipe() victim, _ := newKillVictim(t)
t.Cleanup(func() {
_ = clientSide.Close()
})
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
))
cfg := &config.Config{ //nolint:exhaustruct
ServerName: "test.irc",
}
victim := ircserver.NewTestConn(
log, cfg, serverSide, "victim",
)
returned := make(chan struct{}) returned := make(chan struct{})
go func() { go func() {
@@ -59,66 +53,44 @@ func TestDisconnectDoesNotBlockOnUnresponsiveVictim(
select { select {
case <-returned: case <-returned:
case <-time.After(disconnectBudget): case <-time.After(testTimeout):
t.Fatal( t.Fatal("Disconnect waited for the victim to read")
"Disconnect blocked on the victim's socket; " +
"the killer must not be held hostage",
)
} }
} }
// TestDisconnectNotifiesAndClosesVictim is the other half // TestDisconnectNotifiesAndClosesVictim checks that a
// of the contract: moving the notification off the killer's // victim that reads is sent KILL and ERROR, and that its
// goroutine must not lose it. A victim that does read gets // connection is then closed.
// both the KILL and the ERROR line, and then its socket is
// closed so its read loop unblocks.
func TestDisconnectNotifiesAndClosesVictim(t *testing.T) { func TestDisconnectNotifiesAndClosesVictim(t *testing.T) {
t.Parallel() t.Parallel()
serverSide, clientSide := net.Pipe() victim, clientSide := newKillVictim(t)
received := make(chan []string, 1)
t.Cleanup(func() {
_ = clientSide.Close()
})
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
))
cfg := &config.Config{ //nolint:exhaustruct
ServerName: "test.irc",
}
victim := ircserver.NewTestConn(
log, cfg, serverSide, "victim",
)
lines := make(chan []string, 1)
go func() { go func() {
var got []string var lines []string
scanner := bufio.NewScanner(clientSide) scanner := bufio.NewScanner(clientSide)
for scanner.Scan() { for scanner.Scan() {
got = append(got, scanner.Text()) lines = append(lines, scanner.Text())
} }
lines <- got received <- lines
}() }()
victim.Disconnect("killed by oper") victim.Disconnect("killed by oper")
var got []string var lines []string
select { select {
case got = <-lines: case lines = <-received:
case <-time.After(5 * time.Second): case <-time.After(testTimeout):
t.Fatal("victim socket was never closed") t.Fatal("the victim's connection was not closed")
} }
joined := strings.Join(got, "\n") joined := strings.Join(lines, "\n")
if !strings.Contains(joined, "KILL victim") { if !strings.Contains(joined, "KILL victim :killed by oper") {
t.Errorf("missing KILL line, got: %q", joined) t.Errorf("missing KILL line, got: %q", joined)
} }
+2 -3
View File
@@ -56,9 +56,8 @@ func (s *Server) Listener() net.Listener {
return s.listener return s.listener
} }
// NewTestConn wraps an already-established net.Conn in a // NewTestConn returns a Conn for nick on tcpConn, without a
// Conn so tests can drive connection-level behaviour such // server, database or service behind it.
// as Disconnect without standing up a whole server.
func NewTestConn( func NewTestConn(
log *slog.Logger, log *slog.Logger,
cfg *config.Config, cfg *config.Config,
+130 -143
View File
@@ -4,6 +4,8 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"sneak.berlin/go/neoirc/internal/config"
) )
// TestIntegrationTwoClients is a comprehensive integration // TestIntegrationTwoClients is a comprehensive integration
@@ -777,39 +779,28 @@ func TestIntegrationUserhost(t *testing.T) {
bob := env.dial(t) bob := env.dial(t)
bob.register("bob") bob.register("bob")
// Query single nick. bob.send("AWAY :lunch")
alice.send("USERHOST bob") bob.readUntil(func(l string) bool {
return strings.Contains(l, " 306 ")
aliceReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 302 ")
}) })
assertContains(
t, aliceReply, " 302 ",
"RPL_USERHOST",
)
assertContains(
t, aliceReply, "bob",
"USERHOST contains queried nick",
)
// Query multiple nicks. for _, step := range []struct {
bob.send("USERHOST alice bob") line, want string
}{
{"USERHOST alice", " 302 alice alice=+alice@"},
{"USERHOST alice bob", " :alice=+alice@"},
{"USERHOST alice bob", " bob=-bob@"},
{"USERHOST nobody", " 302 alice :"},
{"USERHOST", " 461 alice USERHOST :"},
} {
alice.send(step.line)
bobReply := bob.readUntil(func(l string) bool { reply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 302 ") return strings.Contains(l, " 302 ") ||
}) strings.Contains(l, " 461 ")
assertContains( })
t, bobReply, " 302 ", assertContains(t, reply, step.want, step.line)
"RPL_USERHOST multi-nick", }
)
assertContains(
t, bobReply, "alice",
"USERHOST multi contains alice",
)
assertContains(
t, bobReply, "bob",
"USERHOST multi contains bob",
)
} }
// TestIntegrationVersion verifies the VERSION command // TestIntegrationVersion verifies the VERSION command
@@ -919,17 +910,54 @@ func TestIntegrationTime(t *testing.T) {
"RPL_TIME", "RPL_TIME",
) )
assertContains( assertContains(
t, aliceReply, "test.irc", t, aliceReply, testServerName,
"TIME reply includes server name", "TIME reply includes server name",
) )
} }
// TestIntegrationKill verifies the KILL command: oper can // becomeOper sends OPER with newTestEnvWithOper's
// kill a user, non-oper cannot, and — most importantly — // credentials and waits for RPL_YOUREOPER.
// that the victim is actually disconnected rather than func (tc *testClient) becomeOper() {
// merely announced as having quit. tc.t.Helper()
//
//nolint:funlen // one KILL scenario asserted end to end tc.send("OPER testoper testpass")
tc.readUntil(func(l string) bool {
return strings.Contains(l, " 381 ")
})
}
// TestIntegrationKillRefused covers the KILL errors: not an
// operator, no such nick, and killing yourself.
func TestIntegrationKillRefused(t *testing.T) {
t.Parallel()
env := newTestEnvWithOper(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
for _, step := range []struct {
line, numeric string
}{
{"KILL bob :nope", " 481 "},
{"OPER testoper testpass", " 381 "},
{"KILL nobody123 :gone", " 401 "},
{"KILL alice :me", " 483 "},
} {
alice.send(step.line)
reply := alice.readUntil(func(l string) bool {
return strings.Contains(l, step.numeric)
})
assertContains(t, reply, step.numeric, step.line)
}
}
// TestIntegrationKill checks that the victim of a KILL is
// told why, is disconnected, and is gone from its channels.
func TestIntegrationKill(t *testing.T) { func TestIntegrationKill(t *testing.T) {
t.Parallel() t.Parallel()
@@ -941,7 +969,6 @@ func TestIntegrationKill(t *testing.T) {
bob := env.dial(t) bob := env.dial(t)
bob.register("bob") bob.register("bob")
// Both join a channel so KILL's QUIT is visible.
alice.joinAndDrain("#killtest") alice.joinAndDrain("#killtest")
bob.joinAndDrain("#killtest") bob.joinAndDrain("#killtest")
@@ -951,30 +978,9 @@ func TestIntegrationKill(t *testing.T) {
strings.Contains(l, "bob") strings.Contains(l, "bob")
}) })
// Non-oper KILL should fail. alice.becomeOper()
alice.send("KILL bob :nope")
aliceKillFail := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 481 ")
})
assertContains(
t, aliceKillFail, " 481 ",
"ERR_NOPRIVILEGES for non-oper KILL",
)
// alice becomes oper.
alice.send("OPER testoper testpass")
alice.readUntil(func(l string) bool {
return strings.Contains(l, " 381 ")
})
// Oper KILL should succeed.
alice.send("KILL bob :bad behavior") alice.send("KILL bob :bad behavior")
// The victim must be told why and then disconnected.
// Reading to EOF is the assertion that matters: a KILL
// that only broadcasts a QUIT leaves bob holding a
// socket that looks alive but delivers nothing.
bobLines := bob.readUntilClosed() bobLines := bob.readUntilClosed()
assertContains( assertContains(
t, bobLines, "KILL", t, bobLines, "KILL",
@@ -1024,17 +1030,6 @@ func TestIntegrationKill(t *testing.T) {
t, aliceWho, "bob", t, aliceWho, "bob",
"killed user must not appear in WHO", "killed user must not appear in WHO",
) )
// KILL nonexistent nick.
alice.send("KILL nobody123 :gone")
aliceNoSuch := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 401 ")
})
assertContains(
t, aliceNoSuch, " 401 ",
"ERR_NOSUCHNICK for KILL missing target",
)
} }
// TestIntegrationWallops verifies the WALLOPS command: // TestIntegrationWallops verifies the WALLOPS command:
@@ -1061,11 +1056,7 @@ func TestIntegrationWallops(t *testing.T) {
"ERR_NOPRIVILEGES for non-oper WALLOPS", "ERR_NOPRIVILEGES for non-oper WALLOPS",
) )
// alice becomes oper. alice.becomeOper()
alice.send("OPER testoper testpass")
alice.readUntil(func(l string) bool {
return strings.Contains(l, " 381 ")
})
// bob sets +w to receive wallops. // bob sets +w to receive wallops.
bob.send("MODE bob +w") bob.send("MODE bob +w")
@@ -1092,6 +1083,52 @@ func TestIntegrationWallops(t *testing.T) {
) )
} }
// TestIntegrationUserMode covers MODE for a nick: changes
// and queries of your own modes in any letter case, refusal
// for another nick, and a rejected mode string changing
// nothing.
func TestIntegrationUserMode(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
const (
notYours = " 502 alice :Can't change mode for other users"
unknown = " 501 alice :Unknown MODE flag"
)
for _, step := range []struct {
line, want string
}{
{"MODE ALICE +w", " 221 alice +w"},
{"MODE bob", notYours},
{"MODE bob -w", notYours},
{"MODE alice xw", unknown},
{"MODE alice -w+z", unknown},
{"MODE alice", " 221 alice +w"},
{"MODE alice +w-w", " 221 alice +"},
} {
alice.send(step.line)
reply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 221 ") ||
strings.Contains(l, " 501 ") ||
strings.Contains(l, " 502 ")
})
last := reply[len(reply)-1]
if !strings.HasSuffix(last, step.want) {
t.Errorf("%s: want %q, got %q", step.line, step.want, last)
}
}
}
// TestIntegrationModeSecret tests +s (secret) channel // TestIntegrationModeSecret tests +s (secret) channel
// mode — verifies that +s can be set and the mode is // mode — verifies that +s can be set and the mode is
// reflected in MODE queries. // reflected in MODE queries.
@@ -1246,79 +1283,29 @@ func TestIntegrationThirdClientObserver(t *testing.T) {
) )
} }
// assertNoEmptyParam fails if any line contains a doubled // TestIntegrationDefaultServerNameFallback checks that with
// space, which is what FormatMessage emits for an empty // SERVER_NAME unset, as it is by default, VERSION, ADMIN and
// non-trailing parameter. A numeric whose server-name // TIME name the server "neoirc".
// parameter came out empty is malformed on the wire even
// though it still contains the numeric code, so the other
// assertions in this file would not catch it.
func assertNoEmptyParam(
t *testing.T,
lines []string,
context string,
) {
t.Helper()
for _, line := range lines {
if strings.Contains(line, " ") {
t.Errorf(
"%s: empty parameter in wire line: %q",
context, line,
)
}
}
}
// TestIntegrationDefaultServerNameFallback runs the wire
// server with SERVER_NAME unset, which is the shipped
// default from config.go, and verifies that VERSION, ADMIN
// and TIME fall back to "neoirc" exactly as the HTTP path
// does instead of emitting an empty server-name parameter.
//
// Every other wire test hardcodes ServerName: "test.irc",
// so none of them exercise the default configuration.
func TestIntegrationDefaultServerNameFallback(t *testing.T) { func TestIntegrationDefaultServerNameFallback(t *testing.T) {
t.Parallel() t.Parallel()
env := newTestEnvWithServerName(t, "") env := newTestEnvWithConfig(t, &config.Config{})
alice := env.dial(t) alice := env.dial(t)
alice.register("alice") alice.register("alice")
alice.send("VERSION") for _, step := range []struct {
line, lastNumeric, want string
}{
{"VERSION", " 351 ", " 351 alice neoirc-test. neoirc "},
{"ADMIN", " 259 ", " 256 alice neoirc "},
{"TIME", " 391 ", " 391 alice neoirc "},
} {
alice.send(step.line)
versionReply := alice.readUntil(func(l string) bool { reply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 351 ") return strings.Contains(l, step.lastNumeric)
}) })
assertNoEmptyParam(t, versionReply, "VERSION") assertContains(t, reply, step.want, step.line)
assertContains( }
t, versionReply, "neoirc",
"VERSION falls back to default server name",
)
alice.send("ADMIN")
adminReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 259 ")
})
assertNoEmptyParam(t, adminReply, "ADMIN")
assertContains(
t, adminReply, " 256 alice neoirc ",
"RPL_ADMINME names the default server",
)
assertContains(
t, adminReply, "admin@neoirc",
"RPL_ADMINEMAIL is a well-formed address",
)
alice.send("TIME")
timeReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 391 ")
})
assertNoEmptyParam(t, timeReply, "TIME")
assertContains(
t, timeReply, " 391 alice neoirc ",
"RPL_TIME names the default server",
)
} }
+28 -101
View File
@@ -36,20 +36,36 @@ type testEnv struct {
srv *ircserver.Server srv *ircserver.Server
} }
// testServerName is the SERVER_NAME of newTestEnv.
const testServerName = "test.irc"
func newTestEnv(t *testing.T) *testEnv { func newTestEnv(t *testing.T) *testEnv {
t.Helper() t.Helper()
return newTestEnvWithServerName(t, "test.irc") return newTestEnvWithConfig(t, &config.Config{
ServerName: testServerName,
MOTD: "Welcome to test IRC",
})
} }
// newTestEnvWithServerName creates a test environment with // newTestEnvWithOper is newTestEnv with the operator name
// an explicit SERVER_NAME. Passing "" exercises the shipped // testoper and password testpass.
// default from config.go, under which the server must fall func newTestEnvWithOper(t *testing.T) *testEnv {
// back to "neoirc" rather than emitting an empty t.Helper()
// server-name parameter.
func newTestEnvWithServerName( return newTestEnvWithConfig(t, &config.Config{
ServerName: testServerName,
MOTD: "Welcome to test IRC",
OperName: "testoper",
OperPassword: "testpass",
})
}
// newTestEnvWithConfig starts an IRC server with cfg on a
// fresh database.
func newTestEnvWithConfig(
t *testing.T, t *testing.T,
serverName string, cfg *config.Config,
) *testEnv { ) *testEnv {
t.Helper() t.Helper()
@@ -81,11 +97,6 @@ func newTestEnvWithServerName(
brk := broker.New() brk := broker.New()
cfg := &config.Config{ //nolint:exhaustruct
ServerName: serverName,
MOTD: "Welcome to test IRC",
}
var listenConfig net.ListenConfig var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0") listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
@@ -129,87 +140,6 @@ func newTestEnvWithServerName(
} }
} }
// newTestEnvWithOper creates a test environment with oper
// credentials configured.
func newTestEnvWithOper(t *testing.T) *testEnv {
t.Helper()
dsn := fmt.Sprintf(
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
t.Name(),
)
conn, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("open db: %v", err)
}
conn.SetMaxOpenConns(1)
_, err = conn.ExecContext(
t.Context(), "PRAGMA foreign_keys = ON",
)
if err != nil {
t.Fatalf("pragma: %v", err)
}
database := db.NewTestDatabaseFromConn(conn)
err = database.RunMigrations(t.Context())
if err != nil {
t.Fatalf("migrate: %v", err)
}
brk := broker.New()
cfg := &config.Config{ //nolint:exhaustruct
ServerName: "test.irc",
MOTD: "Welcome to test IRC",
OperName: "testoper",
OperPassword: "testpass",
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := listener.Addr().String()
err = listener.Close()
if err != nil {
t.Fatalf("close listener: %v", err)
}
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
))
srv := ircserver.NewTestServer(log, cfg, database, brk)
err = srv.Start(addr)
if err != nil {
t.Fatalf("start irc server: %v", err)
}
t.Cleanup(func() {
srv.Stop()
err := conn.Close()
if err != nil {
t.Logf("close db: %v", err)
}
})
return &testEnv{
database: database,
brk: brk,
cfg: cfg,
srv: srv,
}
}
// dial connects to the test server. // dial connects to the test server.
func (env *testEnv) dial(t *testing.T) *testClient { func (env *testEnv) dial(t *testing.T) *testClient {
t.Helper() t.Helper()
@@ -310,11 +240,9 @@ func (tc *testClient) register(nick string) []string {
}) })
} }
// readUntilClosed reads until the peer closes the // readUntilClosed returns the lines received until the
// connection, returning the lines received first. It // server closes the connection, and fails the test if it is
// fails the test if the connection is still open when the // still open after testTimeout.
// read deadline expires, which is what a KILL that never
// terminates the victim's socket looks like.
func (tc *testClient) readUntilClosed() []string { func (tc *testClient) readUntilClosed() []string {
tc.t.Helper() tc.t.Helper()
@@ -331,8 +259,7 @@ func (tc *testClient) readUntilClosed() []string {
err := tc.scanner.Err() err := tc.scanner.Err()
if err != nil { if err != nil {
tc.t.Fatalf( tc.t.Fatalf(
"expected EOF on victim socket, got %v "+ "connection not closed: %v (lines: %v)",
"(lines: %v)",
err, lines, err, lines,
) )
} }
+211 -172
View File
@@ -5,7 +5,9 @@ package service
import ( import (
"context" "context"
"crypto/subtle" "crypto/subtle"
"database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"strconv" "strconv"
@@ -25,9 +27,14 @@ import (
// Error texts that several commands reply with. // Error texts that several commands reply with.
const ( const (
msgNoSuchChannel = "No such channel" msgNoSuchChannel = "No such channel"
msgNoSuchNick = "No such nick/channel"
msgNotChannelOp = "You're not channel operator" msgNotChannelOp = "You're not channel operator"
) )
// maxUserhostNicks is how many nicks one USERHOST answers
// for (RFC 2812).
const maxUserhostNicks = 5
// Params defines the dependencies for creating a Service. // Params defines the dependencies for creating a Service.
type Params struct { type Params struct {
fx.In fx.In
@@ -39,14 +46,11 @@ type Params struct {
Globals *globals.Globals Globals *globals.Globals
} }
// WireConn is a live client connection that a transport // WireConn is a registered IRC connection, which KILL closes.
// registers with the service so that commands such as KILL // HTTP clients hold no connection and register none.
// can reach it. The IRC wire server registers one per
// registered connection; the HTTP transport has no
// long-lived socket and registers nothing.
type WireConn interface { type WireConn interface {
// Disconnect terminates the connection, telling the // Disconnect tells the client why and closes the
// client why before closing the socket. // connection.
Disconnect(reason string) Disconnect(reason string)
} }
@@ -64,7 +68,7 @@ type Service struct {
// New creates a new Service. // New creates a new Service.
func New(params Params) *Service { func New(params Params) *Service {
return &Service{ //nolint:exhaustruct // mutex zero value return &Service{
db: params.Database, db: params.Database,
broker: params.Broker, broker: params.Broker,
config: params.Config, config: params.Config,
@@ -83,7 +87,7 @@ func NewTestService(
globs *globals.Globals, globs *globals.Globals,
log *slog.Logger, log *slog.Logger,
) *Service { ) *Service {
return &Service{ //nolint:exhaustruct // mutex zero value return &Service{
db: database, db: database,
broker: brk, broker: brk,
config: cfg, config: cfg,
@@ -93,46 +97,81 @@ func NewTestService(
} }
} }
// ServerVersion returns the canonical server version string // ServerVersion returns the version that VERSION and INFO
// used by every transport, e.g. "neoirc-1.2.3". Both the // report on both transports, such as "neoirc-1.2.3".
// IRC wire protocol and the HTTP API must report the same
// string, so this is the only place it is built.
func (s *Service) ServerVersion() string { func (s *Service) ServerVersion() string {
name := "neoirc" version := s.globals.Version
ver := "dev" if version == "" {
version = "dev"
if s.globals != nil {
if s.globals.Appname != "" {
name = s.globals.Appname
}
if s.globals.Version != "" {
ver = s.globals.Version
}
} }
return name + "-" + ver return s.globals.Appname + "-" + version
} }
// InfoLines returns the RPL_INFO body. Both transports // InfoLines returns the lines that INFO sends on both
// send exactly these lines so that INFO does not diverge // transports.
// between the wire protocol and the HTTP API.
func (s *Service) InfoLines() []string { func (s *Service) InfoLines() []string {
started := "unknown"
if s.globals != nil && !s.globals.StartTime.IsZero() {
started = s.globals.StartTime.Format(time.RFC1123)
}
return []string{ return []string{
"neoirc — IRC semantics over HTTP", "neoirc — IRC semantics over HTTP",
"Version: " + s.ServerVersion(), "Version: " + s.ServerVersion(),
"Written in Go", "Written in Go",
"Started: " + started, "Started: " + s.globals.StartTime.Format(time.RFC1123),
} }
} }
// RegisterWireConn associates a live wire connection with // UserhostReply returns the RPL_USERHOST text for the first
// its session ID so that KillSession can reach it. // five of the given nicks: "nick=+user@host" entries joined
// by spaces, with * after the nick of an operator and - in
// place of + for a user who is away. Nicks with no session
// are left out. An empty hostname is reported as
// serverName.
func (s *Service) UserhostReply(
ctx context.Context,
nicks []string,
serverName string,
) (string, error) {
if len(nicks) > maxUserhostNicks {
nicks = nicks[:maxUserhostNicks]
}
infos, err := s.db.GetUserhostInfo(ctx, nicks)
if err != nil {
return "", fmt.Errorf("userhost: %w", err)
}
replies := make([]string, 0, len(infos))
for _, info := range infos {
operStar := ""
if info.IsOper {
operStar = "*"
}
away := "+"
if info.AwayMessage != "" {
away = "-"
}
username := info.Username
if username == "" {
username = info.Nick
}
hostname := info.Hostname
if hostname == "" {
hostname = serverName
}
replies = append(replies,
info.Nick+operStar+"="+away+username+"@"+hostname,
)
}
return strings.Join(replies, " "), nil
}
// RegisterWireConn records the IRC connection of a session,
// so that KillUser can close it.
func (s *Service) RegisterWireConn( func (s *Service) RegisterWireConn(
sessionID int64, sessionID int64,
conn WireConn, conn WireConn,
@@ -143,9 +182,9 @@ func (s *Service) RegisterWireConn(
s.wireConns[sessionID] = conn s.wireConns[sessionID] = conn
} }
// UnregisterWireConn removes the association created by // UnregisterWireConn removes what RegisterWireConn recorded,
// RegisterWireConn. It is a no-op if the session has // unless the session has since registered another
// already been rebound to a different connection. // connection.
func (s *Service) UnregisterWireConn( func (s *Service) UnregisterWireConn(
sessionID int64, sessionID int64,
conn WireConn, conn WireConn,
@@ -158,28 +197,90 @@ func (s *Service) UnregisterWireConn(
} }
} }
// KillSession forcibly removes a user from the server: the // KillUser carries out an operator's KILL: the target's
// victim's channel peers are told via QUIT, the victim's // channel peers see it quit, its session is deleted, and
// session is deleted, and any live wire connection it holds // its IRC connection, if it has one, is closed.
// is disconnected. Both the IRC KILL command and the HTTP func (s *Service) KillUser(
// KILL endpoint route through here so the two transports
// cannot diverge.
func (s *Service) KillSession(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID int64,
nick, reason string, nick, targetNick, reason string,
) { ) error {
s.BroadcastQuit(ctx, sessionID, nick, reason) err := s.requireOper(ctx, sessionID)
if err != nil {
return err
}
targetSID, err := s.db.GetSessionByNick(ctx, targetNick)
if errors.Is(err, sql.ErrNoRows) {
return &IRCError{
irc.ErrNoSuchNick,
[]string{targetNick},
msgNoSuchNick,
}
}
if err != nil {
return fmt.Errorf("kill: %w", err)
}
if targetSID == sessionID {
return &IRCError{
irc.ErrCantKillServer,
nil,
"You cannot KILL yourself",
}
}
quitReason := "Killed (" + nick + " (" + reason + "))"
s.BroadcastQuit(ctx, targetSID, targetNick, quitReason)
// A session with no registered wire connection (an
// HTTP-only client) has nothing left to disconnect.
s.wireMu.Lock() s.wireMu.Lock()
conn := s.wireConns[sessionID] conn := s.wireConns[targetSID]
s.wireMu.Unlock() s.wireMu.Unlock()
if conn != nil { if conn != nil {
conn.Disconnect(reason) conn.Disconnect(quitReason)
} }
return nil
}
// SendWallops carries out an operator's WALLOPS: the
// message goes to every user with user mode +w.
func (s *Service) SendWallops(
ctx context.Context,
sessionID int64,
nick, message string,
) error {
err := s.requireOper(ctx, sessionID)
if err != nil {
return err
}
recipients, err := s.db.GetWallopsSessionIDs(ctx)
if err != nil {
return fmt.Errorf("wallops: %w", err)
}
if len(recipients) == 0 {
return nil
}
body, err := json.Marshal([]string{message})
if err != nil {
return fmt.Errorf("wallops: %w", err)
}
_, _, err = s.FanOut(
ctx, irc.CmdWallops, nick, "*",
nil, body, nil, recipients,
)
if err != nil {
return fmt.Errorf("wallops: %w", err)
}
return nil
} }
// IRCError represents an IRC protocol-level error with a // IRCError represents an IRC protocol-level error with a
@@ -550,7 +651,7 @@ func (s *Service) KickUser(
return &IRCError{ return &IRCError{
irc.ErrNoSuchNick, irc.ErrNoSuchNick,
[]string{targetNick}, []string{targetNick},
"No such nick/channel", msgNoSuchNick,
} }
} }
@@ -762,7 +863,7 @@ func (s *Service) ApplyMemberMode(
return &IRCError{ return &IRCError{
irc.ErrNoSuchNick, irc.ErrNoSuchNick,
[]string{targetNick}, []string{targetNick},
"No such nick/channel", msgNoSuchNick,
} }
} }
@@ -905,12 +1006,8 @@ func (s *Service) QueryChannelMode(
return modes + modeParams return modes + modeParams
} }
// QueryUserMode returns the current user mode string for // QueryUserMode returns the session's user mode string,
// the given session (e.g. "+ow", "+w", "+"). A database // such as "+", "+w" or "+ow".
// failure is returned rather than being reported as an
// unset flag: an unreadable mode is not the same as an
// absent one, and reporting "+" for either would tell the
// user they are de-opered when the truth is unknown.
func (s *Service) QueryUserMode( func (s *Service) QueryUserMode(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID int64,
@@ -944,80 +1041,52 @@ func (s *Service) QueryUserMode(
return modes, nil return modes, nil
} }
// userModeOp is a single parsed user-mode change collected // ApplyUserMode applies a user mode string to the session
// by parseUserModeString before any DB writes happen. // and returns the resulting mode string. The whole string
type userModeOp struct { // is parsed before anything is written, and the flags are
char rune // written in one transaction, so a change that is rejected
adding bool // or fails leaves the modes as they were.
}
// ApplyUserMode parses an IRC user-mode string and applies
// the resulting changes atomically. It supports multiple
// sign transitions (e.g. "+w-o", "-w+o", "+o-w+w") and
// rejects malformed input (empty string, no leading sign,
// bare sign with no mode letters, unknown mode letters,
// +o which must be set via OPER) with an IRCError. On
// failure, no persistent change is made: parsing happens
// before any write, and the writes themselves run inside a
// single database transaction that is rolled back whole if
// any statement fails. On success, the resulting mode
// string is returned.
func (s *Service) ApplyUserMode( func (s *Service) ApplyUserMode(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID int64,
modeStr string, modeStr string,
) (string, error) { ) (string, error) {
ops, err := parseUserModeString(modeStr) wallops, oper, err := parseUserModeString(modeStr)
if err != nil { if err != nil {
return "", err return "", err
} }
wallops, oper, err := collapseUserModeOps(ops) err = s.db.SetSessionUserModes(
if err != nil {
return "", err
}
if err := s.db.SetSessionUserModes(
ctx, sessionID, wallops, oper, ctx, sessionID, wallops, oper,
); err != nil { )
s.log.Error( if err != nil {
"apply user modes failed", "error", err,
)
return "", fmt.Errorf("apply user modes: %w", err) return "", fmt.Errorf("apply user modes: %w", err)
} }
return s.QueryUserMode(ctx, sessionID) return s.QueryUserMode(ctx, sessionID)
} }
// parseUserModeString validates and parses a user-mode // parseUserModeString parses a user mode string such as
// string into a list of operations. The string must begin // "+w", "-o" or "+w-o" into the new value of each flag it
// with '+' or '-'; subsequent '+' / '-' characters flip the // names, or nil for a flag it does not name; a later letter
// active sign, and letters between them are applied with // overrides an earlier one. The string must start with + or
// the current sign. Every letter must be a recognized user // - and name at least one mode. w may be set or unset, o
// mode for this server, and '+o' is never allowed via MODE // only unset (OPER sets it). Anything else is rejected with
// (use OPER to become operator). If any character is // ERR_UMODEUNKNOWNFLAG.
// invalid, no operations are returned and an IRCError with
// ERR_UMODEUNKNOWNFLAG (501) is returned.
func parseUserModeString( func parseUserModeString(
modeStr string, modeStr string,
) ([]userModeOp, error) { ) (*bool, *bool, error) {
unknownFlag := &IRCError{ unknownFlag := &IRCError{
Code: irc.ErrUmodeUnknownFlag, irc.ErrUmodeUnknownFlag, nil, "Unknown MODE flag",
Params: nil,
Message: "Unknown MODE flag",
} }
if modeStr == "" { if !strings.HasPrefix(modeStr, "+") &&
return nil, unknownFlag !strings.HasPrefix(modeStr, "-") {
return nil, nil, unknownFlag
} }
first := modeStr[0] var wallops, oper *bool
if first != '+' && first != '-' {
return nil, unknownFlag
}
ops := make([]userModeOp, 0, len(modeStr)-1)
adding := true adding := true
for _, modeChar := range modeStr { for _, modeChar := range modeStr {
@@ -1026,80 +1095,50 @@ func parseUserModeString(
adding = true adding = true
case '-': case '-':
adding = false adding = false
default:
if !isKnownUserModeChar(modeChar) {
return nil, unknownFlag
}
if modeChar == 'o' && adding {
return nil, unknownFlag
}
ops = append(ops, userModeOp{
char: modeChar, adding: adding,
})
}
}
if len(ops) == 0 {
return nil, unknownFlag
}
return ops, nil
}
// isKnownUserModeChar reports whether the character is a
// recognized user mode letter.
func isKnownUserModeChar(modeChar rune) bool {
switch modeChar {
case 'w', 'o':
return true
default:
return false
}
}
// collapseUserModeOps reduces an already-parsed operation
// list to the final desired value of each user mode flag.
// A nil result for a flag means the mode string never
// mentioned it, so it must be left untouched. Later
// operations win over earlier ones for the same letter
// (e.g. "+w-w" ends with wallops off), which matches the
// left-to-right semantics of applying each op in turn.
// parseUserModeString must have validated every character
// and sign before this runs; the default branch here is
// defence-in-depth only.
func collapseUserModeOps(
ops []userModeOp,
) (*bool, *bool, error) {
unknownFlag := &IRCError{
Code: irc.ErrUmodeUnknownFlag,
Params: nil,
Message: "Unknown MODE flag",
}
var wallops, oper *bool
for _, modeOp := range ops {
switch modeOp.char {
case 'w': case 'w':
val := modeOp.adding value := adding
wallops = &val wallops = &value
case 'o': case 'o':
if modeOp.adding { if adding {
return nil, nil, unknownFlag return nil, nil, unknownFlag
} }
val := false value := false
oper = &val oper = &value
default: default:
return nil, nil, unknownFlag return nil, nil, unknownFlag
} }
} }
if wallops == nil && oper == nil {
return nil, nil, unknownFlag
}
return wallops, oper, nil return wallops, oper, nil
} }
// requireOper returns ERR_NOPRIVILEGES unless the session
// is a server operator.
func (s *Service) requireOper(
ctx context.Context,
sessionID int64,
) error {
isOper, err := s.db.IsSessionOper(ctx, sessionID)
if err != nil {
return fmt.Errorf("check oper: %w", err)
}
if !isOper {
return &IRCError{
irc.ErrNoPrivileges,
nil,
"Permission Denied- You're not an IRC operator",
}
}
return nil
}
// broadcastNickChange notifies channel peers of a nick // broadcastNickChange notifies channel peers of a nick
// change. // change.
func (s *Service) broadcastNickChange( func (s *Service) broadcastNickChange(
+144 -363
View File
@@ -10,6 +10,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"slices"
"strings"
"testing" "testing"
"time" "time"
@@ -372,7 +374,6 @@ func TestQueryUserMode(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice") sid := createSession(ctx, t, env.db, "alice")
// Fresh session has no modes.
modes, err := env.svc.QueryUserMode(ctx, sid) modes, err := env.svc.QueryUserMode(ctx, sid)
if err != nil { if err != nil {
t.Fatalf("query user mode: %v", err) t.Fatalf("query user mode: %v", err)
@@ -382,8 +383,7 @@ func TestQueryUserMode(t *testing.T) {
t.Errorf("expected +, got %s", modes) t.Errorf("expected +, got %s", modes)
} }
// Set wallops. setUserModes(ctx, t, env.db, sid, userModes{wallops: true})
_ = env.db.SetSessionWallops(ctx, sid, true)
modes, err = env.svc.QueryUserMode(ctx, sid) modes, err = env.svc.QueryUserMode(ctx, sid)
if err != nil { if err != nil {
@@ -394,8 +394,9 @@ func TestQueryUserMode(t *testing.T) {
t.Errorf("expected +w, got %s", modes) t.Errorf("expected +w, got %s", modes)
} }
// Set oper. setUserModes(
_ = env.db.SetSessionOper(ctx, sid, true) ctx, t, env.db, sid, userModes{oper: true, wallops: true},
)
modes, err = env.svc.QueryUserMode(ctx, sid) modes, err = env.svc.QueryUserMode(ctx, sid)
if err != nil { if err != nil {
@@ -407,408 +408,188 @@ func TestQueryUserMode(t *testing.T) {
} }
} }
// applyUserModeCaseState is the subset of session user-mode func TestUserhostReply(t *testing.T) {
// state the rigorous TestApplyUserMode suite asserts on. It env := newTestEnv(t)
// mirrors the columns (oper, wallops) that the parser is ctx := t.Context()
// permitted to mutate.
type applyUserModeCaseState struct { createSession(ctx, t, env.db, "alice")
bobID := createSession(ctx, t, env.db, "bob")
// No username or hostname, and an operator.
operID, _, _, err := env.db.CreateSession(
ctx, "oper", "", "", "",
)
if err != nil {
t.Fatal(err)
}
setUserModes(ctx, t, env.db, operID, userModes{oper: true})
_, err = env.svc.SetAway(ctx, bobID, "gone fishing")
if err != nil {
t.Fatal(err)
}
reply, err := env.svc.UserhostReply(ctx, []string{
"alice", "bob", "nobody", "oper",
}, "srv")
if err != nil {
t.Fatal(err)
}
want := "alice=+alice@localhost bob=-bob@localhost " +
"oper*=+oper@srv"
if reply != want {
t.Errorf("want %q, got %q", want, reply)
}
reply, err = env.svc.UserhostReply(
ctx, slices.Repeat([]string{"bob"}, 6), "srv",
)
if err != nil {
t.Fatal(err)
}
if strings.Count(reply, "bob=") != 5 {
t.Errorf("want 5 entries for 6 nicks, got %q", reply)
}
}
// userModes is the stored state of a session's user mode
// flags.
type userModes struct {
oper bool oper bool
wallops bool wallops bool
} }
// applyUserModeCase describes one rigor-suite case for // applyUserModeTest is one mode string, the flags stored
// Service.ApplyUserMode: the pre-call DB state, the mode // before it is applied, and the expected outcome. An empty
// string input, and the expected post-call observable state // wantModes means the string is rejected with
// (mode string on success, IRC error code on rejection, and // ERR_UMODEUNKNOWNFLAG, and then after must equal before.
// persisted session state either way). type applyUserModeTest struct {
type applyUserModeCase struct { modeStr string
name string before userModes
initialState applyUserModeCaseState wantModes string
modeStr string after userModes
wantModes string
wantErr bool
wantErrCode irc.IRCMessageType
wantState applyUserModeCaseState
} }
// applyUserModeCases returns every case listed in sneak's func applyUserModeTests() []applyUserModeTest {
// review of PR #96 plus a few adjacent ones. Split across none := userModes{}
// helpers by category so each stays under funlen. oper := userModes{oper: true}
func applyUserModeCases() []applyUserModeCase { wallops := userModes{wallops: true}
cases := applyUserModeHappyPathCases() both := userModes{oper: true, wallops: true}
cases = append(cases, applyUserModeSignTransitionCases()...)
cases = append(cases, applyUserModeMalformedCases()...)
cases = append(cases, applyUserModeUnknownLetterCases()...)
return cases return []applyUserModeTest{
} {"+w", none, "+w", wallops},
{"-w", wallops, "+", none},
// applyUserModeHappyPathCases covers valid single-char and {"-o", oper, "+", none},
// multi-char-without-sign-transition mode operations. {"-wo", both, "+", none},
func applyUserModeHappyPathCases() []applyUserModeCase { {"+w-o", oper, "+w", wallops},
return []applyUserModeCase{ {"-w+w", none, "+w", wallops},
{ {"+w-w", wallops, "+", none},
name: "+w from empty", {"-w+o", both, "", both},
initialState: applyUserModeCaseState{oper: false, wallops: false}, {"+o-w+w", both, "", both},
modeStr: "+w", {"+wo", none, "", none},
wantModes: "+w", {"+wz", none, "", none},
wantErr: false, {"+z", none, "", none},
wantErrCode: 0, {"-x+y", none, "", none},
wantState: applyUserModeCaseState{oper: false, wallops: true}, {"+y-x", none, "", none},
}, {"w", none, "", none},
{ {"xw", wallops, "", wallops},
name: "-w from +w", {"", none, "", none},
initialState: applyUserModeCaseState{oper: false, wallops: true}, {"+", none, "", none},
modeStr: "-w", {"-", none, "", none},
wantModes: "+", {"+-+", none, "", none},
wantErr: false,
wantErrCode: 0,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "-o from +o",
initialState: applyUserModeCaseState{oper: true, wallops: false},
modeStr: "-o",
wantModes: "+",
wantErr: false,
wantErrCode: 0,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "-wo from +ow",
initialState: applyUserModeCaseState{oper: true, wallops: true},
modeStr: "-wo",
wantModes: "+",
wantErr: false,
wantErrCode: 0,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
} }
} }
// applyUserModeSignTransitionCases covers multi-char mode
// strings where '+' and '-' flip partway through. +o is
// never legal via MODE, so strings containing it must be
// rejected atomically.
func applyUserModeSignTransitionCases() []applyUserModeCase {
return []applyUserModeCase{
{
name: "+w-o from +o",
initialState: applyUserModeCaseState{oper: true, wallops: false},
modeStr: "+w-o",
wantModes: "+w",
wantErr: false,
wantErrCode: 0,
wantState: applyUserModeCaseState{oper: false, wallops: true},
},
{
// +o is rejected before any op applies; wallops
// stays set.
name: "-w+o always rejects +o",
initialState: applyUserModeCaseState{oper: true, wallops: true},
modeStr: "-w+o",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: true, wallops: true},
},
{
// Wallops must NOT be cleared; oper must NOT be
// cleared. Rejection is fully atomic.
name: "+o-w+w rejects because of +o",
initialState: applyUserModeCaseState{oper: true, wallops: true},
modeStr: "+o-w+w",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: true, wallops: true},
},
}
}
// applyUserModeMalformedCases covers inputs that lack a
// leading '+' or '-' and inputs that consist of bare signs
// without mode letters. All must be rejected with no side
// effects.
func applyUserModeMalformedCases() []applyUserModeCase {
return []applyUserModeCase{
{
name: "w no prefix rejects",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "w",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
// Prove wallops is NOT cleared — the whole point
// of sneak's review.
name: "xw no prefix rejects (would have been" +
" silently -w before)",
initialState: applyUserModeCaseState{oper: false, wallops: true},
modeStr: "xw",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: true},
},
{
name: "empty string rejects",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "bare + rejects",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "bare - rejects",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "-",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "+-+ rejects (no mode letters)",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+-+",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
}
}
// applyUserModeUnknownLetterCases covers well-formed prefix
// strings that contain unknown mode letters. Rejection must
// be atomic: any valid letters before the invalid one must
// not persist.
func applyUserModeUnknownLetterCases() []applyUserModeCase {
return []applyUserModeCase{
{
name: "-x+y rejects unknown -x",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "-x+y",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "+y-x rejects unknown +y",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+y-x",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "+z unknown mode rejects",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+z",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
// Wallops must NOT be set.
name: "+wz rejects whole thing; +w side effect" +
" must NOT persist",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+wz",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "+wo rejects whole thing; +w side effect" +
" must NOT persist",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+wo",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
}
}
// TestApplyUserMode is the rigorous table-driven suite for
// the shared user-mode parser. It covers every case listed
// in sneak's review of PR #96 plus a few adjacent ones.
// Each case asserts the resulting mode string AND the
// persisted session state, to prove that rejected input
// leaves no side effects.
func TestApplyUserMode(t *testing.T) { func TestApplyUserMode(t *testing.T) {
for _, testCase := range applyUserModeCases() { env := newTestEnv(t)
t.Run(testCase.name, func(t *testing.T) {
runApplyUserModeCase(t, testCase) for i, test := range applyUserModeTests() {
t.Run(test.modeStr, func(t *testing.T) {
ctx := t.Context()
sid := createSession(
ctx, t, env.db, fmt.Sprintf("user%d", i),
)
setUserModes(ctx, t, env.db, sid, test.before)
modes, err := env.svc.ApplyUserMode(
ctx, sid, test.modeStr,
)
checkApplyUserModeResult(t, test.wantModes, modes, err)
got := getUserModes(ctx, t, env.db, sid)
if got != test.after {
t.Errorf(
"stored modes: want %+v, got %+v",
test.after, got,
)
}
}) })
} }
} }
// runApplyUserModeCase executes one applyUserModeCase: seed func checkApplyUserModeResult(
// the session state, invoke ApplyUserMode, and verify both
// the returned value and the post-call persisted state.
func runApplyUserModeCase(
t *testing.T, testCase applyUserModeCase,
) {
t.Helper()
env := newTestEnv(t)
ctx := t.Context()
sid := createSession(ctx, t, env.db, "alice")
seedApplyUserModeState(ctx, t, env.db, sid, testCase.initialState)
result, err := env.svc.ApplyUserMode(
ctx, sid, testCase.modeStr,
)
verifyApplyUserModeOutcome(t, testCase, result, err)
verifyApplyUserModeState(ctx, t, env.db, sid, testCase.wantState)
}
// seedApplyUserModeState installs the pre-call session
// state described by initialState.
func seedApplyUserModeState(
ctx context.Context,
t *testing.T, t *testing.T,
database *db.Database, wantModes, modes string,
sid int64,
initialState applyUserModeCaseState,
) {
t.Helper()
if initialState.oper {
if err := database.SetSessionOper(
ctx, sid, true,
); err != nil {
t.Fatalf("init oper: %v", err)
}
}
if initialState.wallops {
if err := database.SetSessionWallops(
ctx, sid, true,
); err != nil {
t.Fatalf("init wallops: %v", err)
}
}
}
// verifyApplyUserModeOutcome asserts the direct return
// value of ApplyUserMode. It dispatches to the error- or
// success-specific verifier based on wantErr.
func verifyApplyUserModeOutcome(
t *testing.T,
testCase applyUserModeCase,
result string,
err error, err error,
) { ) {
t.Helper() t.Helper()
if testCase.wantErr { if wantModes == "" {
verifyApplyUserModeError(t, testCase, err) var ircErr *service.IRCError
if !errors.As(err, &ircErr) ||
ircErr.Code != irc.ErrUmodeUnknownFlag {
t.Fatalf("want ERR_UMODEUNKNOWNFLAG, got %v", err)
}
return return
} }
verifyApplyUserModeSuccess(t, testCase, result, err)
}
// verifyApplyUserModeError checks that err is a
// *service.IRCError whose code matches wantErrCode.
func verifyApplyUserModeError(
t *testing.T, testCase applyUserModeCase, err error,
) {
t.Helper()
var ircErr *service.IRCError
if !errors.As(err, &ircErr) {
t.Fatalf("expected IRCError, got %v", err)
}
if ircErr.Code != testCase.wantErrCode {
t.Errorf(
"code: want %d got %d",
testCase.wantErrCode, ircErr.Code,
)
}
}
// verifyApplyUserModeSuccess checks that err is nil and the
// returned mode string matches wantModes.
func verifyApplyUserModeSuccess(
t *testing.T,
testCase applyUserModeCase,
result string,
err error,
) {
t.Helper()
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if result != testCase.wantModes { if modes != wantModes {
t.Errorf( t.Errorf("modes: want %q, got %q", wantModes, modes)
"modes: want %q got %q",
testCase.wantModes, result,
)
} }
} }
// verifyApplyUserModeState asserts the post-call persisted func setUserModes(
// session state. This is the atomicity guarantee sneak
// demanded: whether the call succeeded or was rejected, the
// DB must match wantState exactly.
func verifyApplyUserModeState(
ctx context.Context, ctx context.Context,
t *testing.T, t *testing.T,
database *db.Database, database *db.Database,
sid int64, sid int64,
wantState applyUserModeCaseState, modes userModes,
) { ) {
t.Helper() t.Helper()
gotOper, err := database.IsSessionOper(ctx, sid) err := database.SetSessionUserModes(
ctx, sid, &modes.wallops, &modes.oper,
)
if err != nil {
t.Fatalf("set user modes: %v", err)
}
}
func getUserModes(
ctx context.Context,
t *testing.T,
database *db.Database,
sid int64,
) userModes {
t.Helper()
oper, err := database.IsSessionOper(ctx, sid)
if err != nil { if err != nil {
t.Fatalf("read oper: %v", err) t.Fatalf("read oper: %v", err)
} }
gotWallops, err := database.IsSessionWallops(ctx, sid) wallops, err := database.IsSessionWallops(ctx, sid)
if err != nil { if err != nil {
t.Fatalf("read wallops: %v", err) t.Fatalf("read wallops: %v", err)
} }
if gotOper != wantState.oper { return userModes{oper: oper, wallops: wallops}
t.Errorf(
"oper: want %v got %v",
wantState.oper, gotOper,
)
}
if gotWallops != wantState.wallops {
t.Errorf(
"wallops: want %v got %v",
wantState.wallops, gotWallops,
)
}
} }