Compare commits
13 Commits
main
...
feature/87
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edbbdc9ca5 | ||
|
|
dbdef00e91 | ||
|
|
209b0ff364 | ||
|
|
cfff726054 | ||
|
|
534d10d719 | ||
|
|
86813b506b | ||
|
|
c20ad88dfe | ||
|
|
f24e33a310 | ||
|
|
93611dad67 | ||
|
|
abe0cc2c30 | ||
|
|
327ff37059 | ||
|
|
17479c4f44 | ||
|
|
9c4ec966fb |
17
README.md
17
README.md
@@ -2307,8 +2307,8 @@ IRC_LISTEN_ADDR=
|
|||||||
| 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` |
|
| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY`, `USERHOST`, `VERSION`, `ADMIN`, `INFO`, `TIME` |
|
||||||
| Operator | `OPER` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) |
|
| Operator | `OPER`, `KILL`, `WALLOPS` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) |
|
||||||
|
|
||||||
### Protocol Details
|
### Protocol Details
|
||||||
|
|
||||||
@@ -2324,6 +2324,15 @@ IRC_LISTEN_ADDR=
|
|||||||
operator status (`@`).
|
operator status (`@`).
|
||||||
- **Channel modes**: `+m` (moderated), `+t` (topic lock), `+o` (operator),
|
- **Channel modes**: `+m` (moderated), `+t` (topic lock), `+o` (operator),
|
||||||
`+v` (voice)
|
`+v` (voice)
|
||||||
|
- **User modes**: `+o` (operator, set only via `OPER`), `+w` (receives
|
||||||
|
`WALLOPS`). `MODE` for any nick other than your own is rejected with
|
||||||
|
`ERR_USERSDONTMATCH` (502), for both queries and changes. Nick comparison
|
||||||
|
is case-insensitive.
|
||||||
|
- **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
|
||||||
|
`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
|
||||||
|
HTTP API.
|
||||||
|
|
||||||
### Bridge to HTTP API
|
### Bridge to HTTP API
|
||||||
|
|
||||||
@@ -2820,6 +2829,10 @@ guess is borne by the server (bcrypt), not the client.
|
|||||||
login from additional devices via `POST /api/v1/login`
|
login from additional devices via `POST /api/v1/login`
|
||||||
- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for
|
- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for
|
||||||
all API authentication
|
all API authentication
|
||||||
|
- [x] **Tier 3 utility commands** — USERHOST (302), VERSION (351), ADMIN
|
||||||
|
(256–259), INFO (371/374), TIME (391), KILL (oper-only forced
|
||||||
|
disconnect), WALLOPS (oper-only broadcast to +w users)
|
||||||
|
- [x] **User mode +w** — wallops usermode via `MODE nick +w/-w`
|
||||||
|
|
||||||
### Future (1.0+)
|
### Future (1.0+)
|
||||||
|
|
||||||
|
|||||||
@@ -58,3 +58,19 @@ func (database *Database) Close() error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExecForTest runs a raw statement against the test
|
||||||
|
// database. Tests use it to install SQLite triggers that
|
||||||
|
// force a specific write to fail, so that the atomicity of
|
||||||
|
// multi-statement helpers can be exercised.
|
||||||
|
func (database *Database) ExecForTest(
|
||||||
|
ctx context.Context,
|
||||||
|
query string,
|
||||||
|
) error {
|
||||||
|
_, err := database.conn.ExecContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("exec for test: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -2413,3 +2414,205 @@ func (database *Database) SetChannelUserLimit(
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSessionUserModes applies a set of user-mode flag
|
||||||
|
// changes to a session inside a single transaction, so a
|
||||||
|
// multi-mode change such as "+w-o" is all-or-nothing. A nil
|
||||||
|
// pointer means the caller did not mention that mode and
|
||||||
|
// the stored value must be left untouched.
|
||||||
|
func (database *Database) SetSessionUserModes(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
wallops *bool,
|
||||||
|
oper *bool,
|
||||||
|
) error {
|
||||||
|
if wallops == nil && oper == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction, err := database.conn.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if wallops != nil {
|
||||||
|
if _, err := transaction.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE sessions SET is_wallops = ? WHERE id = ?`,
|
||||||
|
boolToInt(*wallops), sessionID,
|
||||||
|
); err != nil {
|
||||||
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
|
return fmt.Errorf(
|
||||||
|
"set session wallops: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if oper != nil {
|
||||||
|
if _, err := transaction.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE sessions SET is_oper = ? WHERE id = ?`,
|
||||||
|
boolToInt(*oper), sessionID,
|
||||||
|
); err != nil {
|
||||||
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
|
return fmt.Errorf("set session oper: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := transaction.Commit(); err != nil {
|
||||||
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
|
return fmt.Errorf("commit user modes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// boolToInt renders a Go bool as the 0/1 integer used for
|
||||||
|
// boolean columns in the SQLite schema.
|
||||||
|
func boolToInt(value bool) int {
|
||||||
|
if value {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// wallops (+w) usermode set.
|
||||||
|
func (database *Database) IsSessionWallops(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
) (bool, error) {
|
||||||
|
var isWallops int
|
||||||
|
|
||||||
|
err := database.conn.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT is_wallops FROM sessions WHERE id = ?`,
|
||||||
|
sessionID,
|
||||||
|
).Scan(&isWallops)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf(
|
||||||
|
"check session wallops: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return isWallops != 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWallopsSessionIDs returns all session IDs that have
|
||||||
|
// the wallops (+w) usermode set.
|
||||||
|
func (database *Database) GetWallopsSessionIDs(
|
||||||
|
ctx context.Context,
|
||||||
|
) ([]int64, error) {
|
||||||
|
rows, err := database.conn.QueryContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT id FROM sessions WHERE is_wallops = 1`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"get wallops sessions: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var ids []int64
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var sessionID int64
|
||||||
|
if scanErr := rows.Scan(&sessionID); scanErr != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"scan wallops session: %w", scanErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids = append(ids, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"iterate wallops sessions: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserhostInfo holds the data needed for RPL_USERHOST.
|
||||||
|
type UserhostInfo struct {
|
||||||
|
Nick string
|
||||||
|
Username string
|
||||||
|
Hostname string
|
||||||
|
IsOper bool
|
||||||
|
AwayMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserhostInfo returns USERHOST info for the given
|
||||||
|
// nicks. Nicks with no session are omitted from the
|
||||||
|
// result; any other database failure is returned, because
|
||||||
|
// an unreadable row is not the same as an absent one.
|
||||||
|
func (database *Database) GetUserhostInfo(
|
||||||
|
ctx context.Context,
|
||||||
|
nicks []string,
|
||||||
|
) ([]UserhostInfo, error) {
|
||||||
|
if len(nicks) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]UserhostInfo, 0, len(nicks))
|
||||||
|
|
||||||
|
for _, nick := range nicks {
|
||||||
|
var info UserhostInfo
|
||||||
|
|
||||||
|
err := database.conn.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT nick, username, hostname,
|
||||||
|
is_oper, away_message
|
||||||
|
FROM sessions WHERE nick = ?`,
|
||||||
|
nick,
|
||||||
|
).Scan(
|
||||||
|
&info.Nick, &info.Username, &info.Hostname,
|
||||||
|
&info.IsOper, &info.AwayMessage,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
continue // nick not online
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"userhost lookup %q: %w", nick, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1488,3 +1488,127 @@ func TestChannelUserLimit(t *testing.T) {
|
|||||||
t.Fatalf("expected 0, got %d", limit)
|
t.Fatalf("expected 0, got %d", limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSetSessionUserModesIsAtomic proves that a multi-mode
|
||||||
|
// change is all-or-nothing. A trigger makes the is_oper
|
||||||
|
// UPDATE fail after the is_wallops UPDATE has already run,
|
||||||
|
// 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) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
database := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sessionID, _, _, err := database.CreateSession(
|
||||||
|
ctx, "alice", "", "", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = database.ExecForTest(ctx,
|
||||||
|
`CREATE TRIGGER reject_oper
|
||||||
|
BEFORE UPDATE OF is_oper ON sessions
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'oper write rejected');
|
||||||
|
END`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wallops := true
|
||||||
|
oper := false
|
||||||
|
|
||||||
|
err = database.SetSessionUserModes(
|
||||||
|
ctx, sessionID, &wallops, &oper,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected the rejected oper write to fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
gotWallops, err := database.IsSessionWallops(
|
||||||
|
ctx, sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotWallops {
|
||||||
|
t.Error(
|
||||||
|
"wallops persisted despite the transaction " +
|
||||||
|
"failing; the apply stage is not atomic",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetSessionUserModesAppliesBoth is the success-path
|
||||||
|
// counterpart: when nothing fails, both flags are written,
|
||||||
|
// and a nil pointer leaves that flag untouched.
|
||||||
|
func TestSetSessionUserModesAppliesBoth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
database := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sessionID, _, _, err := database.CreateSession(
|
||||||
|
ctx, "alice", "", "", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.SetSessionOper(
|
||||||
|
ctx, sessionID, true,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wallops := true
|
||||||
|
oper := false
|
||||||
|
|
||||||
|
if err := database.SetSessionUserModes(
|
||||||
|
ctx, sessionID, &wallops, &oper,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotWallops, err := database.IsSessionWallops(
|
||||||
|
ctx, sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotOper, err := database.IsSessionOper(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !gotWallops || gotOper {
|
||||||
|
t.Errorf(
|
||||||
|
"want wallops=true oper=false, got %v/%v",
|
||||||
|
gotWallops, gotOper,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A nil pointer must leave the stored value alone.
|
||||||
|
if err := database.SetSessionUserModes(
|
||||||
|
ctx, sessionID, nil, nil,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotWallops, err = database.IsSessionWallops(
|
||||||
|
ctx, sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !gotWallops {
|
||||||
|
t.Error("nil pointers must not clear wallops")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
hostname TEXT NOT NULL DEFAULT '',
|
hostname TEXT NOT NULL DEFAULT '',
|
||||||
ip TEXT NOT NULL DEFAULT '',
|
ip TEXT NOT NULL DEFAULT '',
|
||||||
is_oper INTEGER NOT NULL DEFAULT 0,
|
is_oper INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_wallops INTEGER NOT NULL DEFAULT 0,
|
||||||
password_hash TEXT NOT NULL DEFAULT '',
|
password_hash TEXT NOT NULL DEFAULT '',
|
||||||
signing_key TEXT NOT NULL DEFAULT '',
|
signing_key TEXT NOT NULL DEFAULT '',
|
||||||
away_message TEXT NOT NULL DEFAULT '',
|
away_message TEXT NOT NULL DEFAULT '',
|
||||||
|
|||||||
@@ -969,10 +969,12 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
switch command {
|
switch command {
|
||||||
case irc.CmdAway:
|
case irc.CmdAway, irc.CmdNick,
|
||||||
hdlr.handleAway(
|
irc.CmdPass, irc.CmdInvite:
|
||||||
|
hdlr.dispatchBodyOnlyCommand(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, bodyLines,
|
sessionID, clientID, nick,
|
||||||
|
command, bodyLines,
|
||||||
)
|
)
|
||||||
case irc.CmdPrivmsg, irc.CmdNotice:
|
case irc.CmdPrivmsg, irc.CmdNotice:
|
||||||
hdlr.handlePrivmsg(
|
hdlr.handlePrivmsg(
|
||||||
@@ -991,27 +993,12 @@ 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,
|
||||||
@@ -1022,12 +1009,15 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
hdlr.handleQuit(
|
hdlr.handleQuit(
|
||||||
writer, request, sessionID, nick, body,
|
writer, request, sessionID, nick, body,
|
||||||
)
|
)
|
||||||
case irc.CmdOper:
|
case irc.CmdOper, irc.CmdKill, irc.CmdWallops:
|
||||||
hdlr.handleOper(
|
hdlr.dispatchOperCommand(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, bodyLines,
|
sessionID, clientID, nick,
|
||||||
|
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,
|
||||||
@@ -1082,6 +1072,11 @@ func (hdlr *Handlers) dispatchQueryCommand(
|
|||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
)
|
)
|
||||||
|
case irc.CmdUserhost:
|
||||||
|
hdlr.handleUserhost(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
@@ -1874,7 +1869,8 @@ 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,
|
||||||
@@ -1900,6 +1896,34 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1956,15 +1980,11 @@ func (hdlr *Handlers) handleMode(
|
|||||||
|
|
||||||
channel := target
|
channel := target
|
||||||
if !strings.HasPrefix(channel, "#") {
|
if !strings.HasPrefix(channel, "#") {
|
||||||
// User mode query — return empty modes.
|
hdlr.handleUserMode(
|
||||||
hdlr.enqueueNumeric(
|
writer, request,
|
||||||
request.Context(), clientID,
|
sessionID, clientID, nick, target,
|
||||||
irc.RplUmodeIs, nick, nil, "+",
|
bodyLines,
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ func newTestHandlers(
|
|||||||
Config: cfg,
|
Config: cfg,
|
||||||
Database: database,
|
Database: database,
|
||||||
Broker: brk,
|
Broker: brk,
|
||||||
|
Globals: globs,
|
||||||
})
|
})
|
||||||
|
|
||||||
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
|
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
|
||||||
|
|||||||
576
internal/handlers/utility.go
Normal file
576
internal/handlers/utility.go
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxUserhostNicks is the maximum number of nicks allowed
|
||||||
|
// in a single USERHOST query (RFC 2812).
|
||||||
|
const maxUserhostNicks = 5
|
||||||
|
|
||||||
|
// dispatchBodyOnlyCommand routes commands that take
|
||||||
|
// (writer, request, sessionID, clientID, nick, bodyLines).
|
||||||
|
func (hdlr *Handlers) dispatchBodyOnlyCommand(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick, command string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
switch command {
|
||||||
|
case irc.CmdAway:
|
||||||
|
hdlr.handleAway(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
|
)
|
||||||
|
case irc.CmdNick:
|
||||||
|
hdlr.handleNick(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
|
)
|
||||||
|
case irc.CmdPass:
|
||||||
|
hdlr.handlePass(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
|
)
|
||||||
|
case irc.CmdInvite:
|
||||||
|
hdlr.handleInvite(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatchOperCommand routes oper-related commands (OPER,
|
||||||
|
// KILL, WALLOPS) to their handlers.
|
||||||
|
func (hdlr *Handlers) dispatchOperCommand(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick, command string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
switch command {
|
||||||
|
case irc.CmdOper:
|
||||||
|
hdlr.handleOper(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
|
)
|
||||||
|
case irc.CmdKill:
|
||||||
|
hdlr.handleKill(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
|
)
|
||||||
|
case irc.CmdWallops:
|
||||||
|
hdlr.handleWallops(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUserhost handles the USERHOST command.
|
||||||
|
// Returns user@host info for up to 5 nicks.
|
||||||
|
func (hdlr *Handlers) handleUserhost(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
lines := bodyLines()
|
||||||
|
if len(lines) == 0 {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrNeedMoreParams, nick,
|
||||||
|
[]string{irc.CmdUserhost},
|
||||||
|
"Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit to 5 nicks per RFC 2812.
|
||||||
|
nicks := lines
|
||||||
|
if len(nicks) > maxUserhostNicks {
|
||||||
|
nicks = nicks[:maxUserhostNicks]
|
||||||
|
}
|
||||||
|
|
||||||
|
infos, err := hdlr.params.Database.GetUserhostInfo(
|
||||||
|
ctx, nicks,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"userhost query failed", "error", err,
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
replyStr := hdlr.buildUserhostReply(infos)
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplUserHost, nick, nil,
|
||||||
|
replyStr,
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildUserhostReply builds the RPL_USERHOST reply
|
||||||
|
// string per RFC 2812.
|
||||||
|
func (hdlr *Handlers) buildUserhostReply(
|
||||||
|
infos []db.UserhostInfo,
|
||||||
|
) string {
|
||||||
|
replies := make([]string, 0, len(infos))
|
||||||
|
|
||||||
|
for idx := range infos {
|
||||||
|
info := &infos[idx]
|
||||||
|
|
||||||
|
username := info.Username
|
||||||
|
if username == "" {
|
||||||
|
username = info.Nick
|
||||||
|
}
|
||||||
|
|
||||||
|
hostname := info.Hostname
|
||||||
|
if hostname == "" {
|
||||||
|
hostname = hdlr.serverName()
|
||||||
|
}
|
||||||
|
|
||||||
|
operStar := ""
|
||||||
|
if info.IsOper {
|
||||||
|
operStar = "*"
|
||||||
|
}
|
||||||
|
|
||||||
|
awayPrefix := "+"
|
||||||
|
if info.AwayMessage != "" {
|
||||||
|
awayPrefix = "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
replies = append(replies,
|
||||||
|
info.Nick+operStar+"="+
|
||||||
|
awayPrefix+username+"@"+hostname,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(replies, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleVersion handles the VERSION command.
|
||||||
|
// Returns the server version string.
|
||||||
|
func (hdlr *Handlers) handleVersion(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
srvName := hdlr.serverName()
|
||||||
|
version := hdlr.svc.ServerVersion()
|
||||||
|
|
||||||
|
// 351 RPL_VERSION
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplVersion, nick,
|
||||||
|
[]string{version + ".", srvName},
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdmin handles the ADMIN command.
|
||||||
|
// Returns server admin contact info.
|
||||||
|
func (hdlr *Handlers) handleAdmin(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
srvName := hdlr.serverName()
|
||||||
|
|
||||||
|
// 256 RPL_ADMINME
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplAdminMe, nick,
|
||||||
|
[]string{srvName},
|
||||||
|
"Administrative info",
|
||||||
|
)
|
||||||
|
|
||||||
|
// 257 RPL_ADMINLOC1
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplAdminLoc1, nick, nil,
|
||||||
|
"neoirc server",
|
||||||
|
)
|
||||||
|
|
||||||
|
// 258 RPL_ADMINLOC2
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplAdminLoc2, nick, nil,
|
||||||
|
"IRC over HTTP",
|
||||||
|
)
|
||||||
|
|
||||||
|
// 259 RPL_ADMINEMAIL
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplAdminEmail, nick, nil,
|
||||||
|
"admin@"+srvName,
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleInfo handles the INFO command.
|
||||||
|
// Returns server software information.
|
||||||
|
func (hdlr *Handlers) handleInfo(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
for _, line := range hdlr.svc.InfoLines() {
|
||||||
|
// 371 RPL_INFO
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplInfo, nick, nil,
|
||||||
|
line,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 374 RPL_ENDOFINFO
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplEndOfInfo, nick, nil,
|
||||||
|
"End of /INFO list",
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTime handles the TIME command.
|
||||||
|
// Returns the server's local time in RFC format.
|
||||||
|
func (hdlr *Handlers) handleTime(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
srvName := hdlr.serverName()
|
||||||
|
|
||||||
|
// 391 RPL_TIME
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplTime, nick,
|
||||||
|
[]string{srvName},
|
||||||
|
time.Now().Format(time.RFC1123),
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleKill handles the KILL command.
|
||||||
|
// Forcibly disconnects a user (oper only).
|
||||||
|
func (hdlr *Handlers) handleKill(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
// Check oper status.
|
||||||
|
isOper, err := hdlr.params.Database.IsSessionOper(
|
||||||
|
ctx, sessionID,
|
||||||
|
)
|
||||||
|
if err != nil || !isOper {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrNoPrivileges, nick, nil,
|
||||||
|
"Permission Denied- You're not an IRC operator",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := bodyLines()
|
||||||
|
|
||||||
|
var targetNick string
|
||||||
|
if len(lines) > 0 {
|
||||||
|
targetNick = strings.TrimSpace(lines[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetNick == "" {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrNeedMoreParams, nick,
|
||||||
|
[]string{irc.CmdKill},
|
||||||
|
"Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reason := "KILLed"
|
||||||
|
if len(lines) > 1 {
|
||||||
|
reason = lines[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
targetSID, lookupErr := hdlr.params.Database.
|
||||||
|
GetSessionByNick(ctx, targetNick)
|
||||||
|
if lookupErr != nil {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrNoSuchNick, nick,
|
||||||
|
[]string{targetNick},
|
||||||
|
"No such nick/channel",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not allow killing yourself.
|
||||||
|
if targetSID == sessionID {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrCantKillServer, nick, nil,
|
||||||
|
"You cannot KILL yourself",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWallops handles the WALLOPS command.
|
||||||
|
// Broadcasts a message to all users with +w usermode
|
||||||
|
// (oper only).
|
||||||
|
func (hdlr *Handlers) handleWallops(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
// Check oper status.
|
||||||
|
isOper, err := hdlr.params.Database.IsSessionOper(
|
||||||
|
ctx, sessionID,
|
||||||
|
)
|
||||||
|
if err != nil || !isOper {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrNoPrivileges, nick, nil,
|
||||||
|
"Permission Denied- You're not an IRC operator",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := bodyLines()
|
||||||
|
if len(lines) == 0 {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrNeedMoreParams, nick,
|
||||||
|
[]string{irc.CmdWallops},
|
||||||
|
"Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
message := strings.Join(lines, " ")
|
||||||
|
|
||||||
|
wallopsSIDs, err := hdlr.params.Database.
|
||||||
|
GetWallopsSessionIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"get wallops sessions failed", "error", err,
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(wallopsSIDs) > 0 {
|
||||||
|
body, mErr := json.Marshal([]string{message})
|
||||||
|
if mErr != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"marshal wallops body", "error", mErr,
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = hdlr.fanOutSilent(
|
||||||
|
request, irc.CmdWallops, nick, "*",
|
||||||
|
json.RawMessage(body), wallopsSIDs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUserMode handles user mode queries and changes
|
||||||
|
// (e.g., MODE nick, MODE nick +w). Delegates to the
|
||||||
|
// shared service.ApplyUserMode / service.QueryUserMode so
|
||||||
|
// that mode string processing is identical for both the
|
||||||
|
// HTTP API and IRC wire protocol.
|
||||||
|
func (hdlr *Handlers) handleUserMode(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick, target string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrUsersDoNotMatch, nick, nil,
|
||||||
|
"Can't change mode for other users",
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
newModes, err := hdlr.svc.ApplyUserMode(
|
||||||
|
ctx, sessionID, modeStr,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplUmodeIs, nick, nil,
|
||||||
|
newModes,
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
1071
internal/handlers/utility_test.go
Normal file
1071
internal/handlers/utility_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -349,7 +349,10 @@ func (c *Conn) handleQuit(msg *Message) {
|
|||||||
|
|
||||||
c.send("ERROR :Closing Link: " + c.hostname +
|
c.send("ERROR :Closing Link: " + c.hostname +
|
||||||
" (Quit: " + reason + ")")
|
" (Quit: " + reason + ")")
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
c.closed = true
|
c.closed = true
|
||||||
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleTopic gets or sets a channel topic via the shared
|
// handleTopic gets or sets a channel topic via the shared
|
||||||
@@ -431,7 +434,7 @@ func (c *Conn) handleMode(
|
|||||||
if strings.HasPrefix(target, "#") {
|
if strings.HasPrefix(target, "#") {
|
||||||
c.handleChannelMode(ctx, msg)
|
c.handleChannelMode(ctx, msg)
|
||||||
} else {
|
} else {
|
||||||
c.handleUserMode(msg)
|
c.handleUserMode(ctx, msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,10 +697,13 @@ func (c *Conn) applyChannelModes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleUserMode handles MODE for users.
|
// handleUserMode handles MODE for users.
|
||||||
func (c *Conn) handleUserMode(msg *Message) {
|
func (c *Conn) handleUserMode(
|
||||||
|
ctx context.Context,
|
||||||
|
msg *Message,
|
||||||
|
) {
|
||||||
target := msg.Params[0]
|
target := msg.Params[0]
|
||||||
|
|
||||||
if !strings.EqualFold(target, c.nick) {
|
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",
|
||||||
@@ -706,8 +712,48 @@ func (c *Conn) handleUserMode(msg *Message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// We don't support user modes beyond the basics.
|
// Mode query (no mode string).
|
||||||
c.sendNumeric(irc.RplUmodeIs, "+")
|
if len(msg.Params) < 2 { //nolint:mnd
|
||||||
|
modes, err := c.svc.QueryUserMode(
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrUmodeUnknownFlag,
|
||||||
|
"Unknown MODE flag",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.sendNumeric(irc.RplUmodeIs, newModes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleNames replies with channel member list.
|
// handleNames replies with channel member list.
|
||||||
@@ -1299,3 +1345,180 @@ func (c *Conn) handleUserhost(
|
|||||||
strings.Join(replies, " "),
|
strings.Join(replies, " "),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleVersion replies with the server version string.
|
||||||
|
func (c *Conn) handleVersion() {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplVersion,
|
||||||
|
c.svc.ServerVersion()+".", c.serverSfx,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdmin replies with server admin info.
|
||||||
|
func (c *Conn) handleAdmin() {
|
||||||
|
srvName := c.serverSfx
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplAdminMe,
|
||||||
|
srvName, "Administrative info",
|
||||||
|
)
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplAdminLoc1,
|
||||||
|
"neoirc server",
|
||||||
|
)
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplAdminLoc2,
|
||||||
|
"IRC over HTTP",
|
||||||
|
)
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplAdminEmail,
|
||||||
|
"admin@"+srvName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleInfo replies with server software info.
|
||||||
|
func (c *Conn) handleInfo() {
|
||||||
|
for _, line := range c.svc.InfoLines() {
|
||||||
|
c.sendNumeric(irc.RplInfo, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplEndOfInfo,
|
||||||
|
"End of /INFO list",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTime replies with the server's current time.
|
||||||
|
func (c *Conn) handleTime() {
|
||||||
|
srvName := c.serverSfx
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplTime,
|
||||||
|
srvName, time.Now().Format(time.RFC1123),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleKillCmd forcibly disconnects a target user (oper
|
||||||
|
// only).
|
||||||
|
func (c *Conn) handleKillCmd(
|
||||||
|
ctx context.Context,
|
||||||
|
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 {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNeedMoreParams,
|
||||||
|
"KILL", "Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetNick := msg.Params[0]
|
||||||
|
|
||||||
|
reason := "KILLed"
|
||||||
|
if len(msg.Params) > 1 {
|
||||||
|
reason = msg.Params[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
killerNick := c.currentNick()
|
||||||
|
|
||||||
|
if strings.EqualFold(targetNick, killerNick) {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrCantKillServer,
|
||||||
|
"You cannot KILL yourself",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetSID, lookupErr := c.database.GetSessionByNick(
|
||||||
|
ctx, targetNick,
|
||||||
|
)
|
||||||
|
if lookupErr != nil {
|
||||||
|
c.sendNumeric(
|
||||||
|
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).
|
||||||
|
func (c *Conn) handleWallopsCmd(
|
||||||
|
ctx context.Context,
|
||||||
|
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 {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNeedMoreParams,
|
||||||
|
"WALLOPS", "Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
message := msg.Params[0]
|
||||||
|
|
||||||
|
wallopsSIDs, wallErr := c.database.
|
||||||
|
GetWallopsSessionIDs(ctx)
|
||||||
|
if wallErr != nil {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const (
|
|||||||
maxLineLen = 512
|
maxLineLen = 512
|
||||||
readTimeout = 5 * time.Minute
|
readTimeout = 5 * time.Minute
|
||||||
writeTimeout = 30 * time.Second
|
writeTimeout = 30 * time.Second
|
||||||
|
killWriteWindow = 2 * time.Second
|
||||||
dnsTimeout = 3 * time.Second
|
dnsTimeout = 3 * time.Second
|
||||||
pollInterval = 100 * time.Millisecond
|
pollInterval = 100 * time.Millisecond
|
||||||
pingInterval = 90 * time.Second
|
pingInterval = 90 * time.Second
|
||||||
@@ -46,6 +47,12 @@ type Conn struct {
|
|||||||
serverSfx string
|
serverSfx string
|
||||||
commands map[string]cmdHandler
|
commands map[string]cmdHandler
|
||||||
|
|
||||||
|
// writeMu serializes writes to conn. A connection is
|
||||||
|
// written to by its own read loop, by its relay
|
||||||
|
// goroutine, and — when an operator KILLs it — by
|
||||||
|
// another client's goroutine.
|
||||||
|
writeMu sync.Mutex
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
nick string
|
nick string
|
||||||
username string
|
username string
|
||||||
@@ -62,6 +69,7 @@ type Conn struct {
|
|||||||
|
|
||||||
lastQueueID int64
|
lastQueueID int64
|
||||||
closed bool
|
closed bool
|
||||||
|
killed bool
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +106,76 @@ func newConn(
|
|||||||
return conn
|
return conn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disconnect terminates the connection on behalf of an
|
||||||
|
// operator KILL issued from either transport. The victim
|
||||||
|
// is told why, then its socket is closed so that the read
|
||||||
|
// loop unblocks and serve() returns; without the close the
|
||||||
|
// victim would keep a socket that looks alive but silently
|
||||||
|
// delivers nothing. Disconnect is called from the killer's
|
||||||
|
// goroutine, never the victim's.
|
||||||
|
//
|
||||||
|
// The notify-and-close half runs on its own goroutine and
|
||||||
|
// under a short deadline. There is no per-client send
|
||||||
|
// queue: send() writes straight to the victim's socket, so
|
||||||
|
// a victim that has stopped reading would otherwise stall
|
||||||
|
// the killer for the full writeTimeout on each of the two
|
||||||
|
// writes -- wedging the operator's own serve() loop on the
|
||||||
|
// IRC path, or the API request on the HTTP path. Any
|
||||||
|
// client could trigger that deliberately. Disconnect
|
||||||
|
// therefore returns as soon as the victim is marked closed;
|
||||||
|
// the socket is closed shortly afterwards regardless of
|
||||||
|
// whether the notification could be delivered.
|
||||||
|
func (c *Conn) Disconnect(reason string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
|
||||||
|
if c.closed {
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.closed = true
|
||||||
|
c.killed = true
|
||||||
|
nick := c.nick
|
||||||
|
host := c.hostname
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if nick == "" {
|
||||||
|
nick = "*"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the relay goroutine, which would otherwise keep
|
||||||
|
// polling a queue belonging to a deleted session.
|
||||||
|
if c.cancel != nil {
|
||||||
|
c.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
go c.notifyKilledAndClose(nick, host, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyKilledAndClose delivers the KILL and ERROR lines to
|
||||||
|
// a killed victim and then closes its socket. It runs on a
|
||||||
|
// goroutine owned by neither the killer nor the victim, and
|
||||||
|
// bounds both writes with killWriteWindow so an unresponsive
|
||||||
|
// victim cannot hold the socket open indefinitely.
|
||||||
|
func (c *Conn) notifyKilledAndClose(
|
||||||
|
nick, host, reason string,
|
||||||
|
) {
|
||||||
|
defer c.conn.Close() //nolint:errcheck
|
||||||
|
|
||||||
|
c.sendWithin(
|
||||||
|
killWriteWindow,
|
||||||
|
FormatMessage(
|
||||||
|
c.serverSfx, irc.CmdKill, nick, reason,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
c.sendWithin(
|
||||||
|
killWriteWindow,
|
||||||
|
"ERROR :Closing Link: "+host+
|
||||||
|
" ("+reason+")",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// buildCommandMap returns a map from IRC command strings
|
// buildCommandMap returns a map from IRC command strings
|
||||||
// to handler functions.
|
// to handler functions.
|
||||||
func (c *Conn) buildCommandMap() map[string]cmdHandler {
|
func (c *Conn) buildCommandMap() map[string]cmdHandler {
|
||||||
@@ -130,7 +208,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,
|
"USERHOST": c.handleUserhost,
|
||||||
|
irc.CmdVersion: func(context.Context, *Message) { c.handleVersion() },
|
||||||
|
irc.CmdAdmin: func(context.Context, *Message) { c.handleAdmin() },
|
||||||
|
irc.CmdInfo: func(context.Context, *Message) { c.handleInfo() },
|
||||||
|
irc.CmdTime: func(context.Context, *Message) { c.handleTime() },
|
||||||
|
irc.CmdKill: c.handleKillCmd,
|
||||||
|
irc.CmdWallops: c.handleWallopsCmd,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +263,7 @@ func (c *Conn) serve(ctx context.Context) {
|
|||||||
|
|
||||||
c.handleMessage(ctx, msg)
|
c.handleMessage(ctx, msg)
|
||||||
|
|
||||||
if c.closed {
|
if c.isClosed() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,24 +272,65 @@ func (c *Conn) serve(ctx context.Context) {
|
|||||||
func (c *Conn) cleanup(ctx context.Context) {
|
func (c *Conn) cleanup(ctx context.Context) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
wasRegistered := c.registered
|
wasRegistered := c.registered
|
||||||
|
wasKilled := c.killed
|
||||||
sessID := c.sessionID
|
sessID := c.sessionID
|
||||||
nick := c.nick
|
nick := c.nick
|
||||||
c.closed = true
|
c.closed = true
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
if wasRegistered && sessID > 0 {
|
if wasRegistered && sessID > 0 {
|
||||||
c.svc.BroadcastQuit(
|
c.svc.UnregisterWireConn(sessID, c)
|
||||||
ctx, sessID, nick, "Connection closed",
|
|
||||||
)
|
// A KILLed session has already been broadcast and
|
||||||
|
// deleted by the killer; broadcasting again would
|
||||||
|
// fan out a QUIT for a session row that is gone.
|
||||||
|
if !wasKilled {
|
||||||
|
c.svc.BroadcastQuit(
|
||||||
|
ctx, sessID, nick, "Connection closed",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.conn.Close() //nolint:errcheck,gosec
|
c.conn.Close() //nolint:errcheck,gosec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isClosed reports whether the connection has been marked
|
||||||
|
// for teardown, either by QUIT or by an operator KILL.
|
||||||
|
func (c *Conn) isClosed() bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
return c.closed
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentNick returns the connection's registered nick.
|
||||||
|
// c.nick is written under c.mu during registration and
|
||||||
|
// NICK changes, so every read must take the mutex.
|
||||||
|
func (c *Conn) currentNick() string {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
return c.nick
|
||||||
|
}
|
||||||
|
|
||||||
// send writes a formatted IRC line to the connection.
|
// send writes a formatted IRC line to the connection.
|
||||||
func (c *Conn) send(line string) {
|
func (c *Conn) send(line string) {
|
||||||
|
c.sendWithin(writeTimeout, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendWithin writes a formatted IRC line to the connection
|
||||||
|
// under the given write deadline. Callers that must not be
|
||||||
|
// held hostage by an unresponsive peer pass a shorter window
|
||||||
|
// than writeTimeout.
|
||||||
|
func (c *Conn) sendWithin(
|
||||||
|
timeout time.Duration,
|
||||||
|
line string,
|
||||||
|
) {
|
||||||
|
c.writeMu.Lock()
|
||||||
|
defer c.writeMu.Unlock()
|
||||||
|
|
||||||
_ = c.conn.SetWriteDeadline(
|
_ = c.conn.SetWriteDeadline(
|
||||||
time.Now().Add(writeTimeout),
|
time.Now().Add(timeout),
|
||||||
)
|
)
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(c.conn, "%s\r\n", line)
|
_, _ = fmt.Fprintf(c.conn, "%s\r\n", line)
|
||||||
@@ -386,7 +511,10 @@ func (c *Conn) completeRegistration(ctx context.Context) {
|
|||||||
"failed to create session", "error", err,
|
"failed to create session", "error", err,
|
||||||
)
|
)
|
||||||
c.send("ERROR :Internal server error")
|
c.send("ERROR :Internal server error")
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
c.closed = true
|
c.closed = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -397,6 +525,10 @@ 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
|
||||||
|
// KILL from either transport can disconnect it.
|
||||||
|
c.svc.RegisterWireConn(sessionID, c)
|
||||||
|
|
||||||
// If PASS was provided before registration, set the
|
// If PASS was provided before registration, set the
|
||||||
// session password.
|
// session password.
|
||||||
if c.passWord != "" && len(c.passWord) >= minPasswordLen {
|
if c.passWord != "" && len(c.passWord) >= minPasswordLen {
|
||||||
|
|||||||
128
internal/ircserver/conn_test.go
Normal file
128
internal/ircserver/conn_test.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package ircserver_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/ircserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
// disconnectBudget is how long Disconnect is allowed to
|
||||||
|
// take when the victim never reads. It is far below the
|
||||||
|
// 30s writeTimeout that the old synchronous implementation
|
||||||
|
// would have burned on each of its two writes.
|
||||||
|
const disconnectBudget = 2 * time.Second
|
||||||
|
|
||||||
|
// TestDisconnectDoesNotBlockOnUnresponsiveVictim proves
|
||||||
|
// that an operator KILL cannot be stalled by its target.
|
||||||
|
// The victim's socket is a net.Pipe, so every write blocks
|
||||||
|
// until the peer reads and the peer here never does. The
|
||||||
|
// old implementation performed both notification writes on
|
||||||
|
// the killer's goroutine, which wedged the operator's own
|
||||||
|
// serve() loop on the IRC path and the API request on the
|
||||||
|
// HTTP path for as long as the victim cared to stay silent.
|
||||||
|
func TestDisconnectDoesNotBlockOnUnresponsiveVictim(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
serverSide, clientSide := net.Pipe()
|
||||||
|
|
||||||
|
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{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
victim.Disconnect("killed by oper")
|
||||||
|
close(returned)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-returned:
|
||||||
|
case <-time.After(disconnectBudget):
|
||||||
|
t.Fatal(
|
||||||
|
"Disconnect blocked on the victim's socket; " +
|
||||||
|
"the killer must not be held hostage",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDisconnectNotifiesAndClosesVictim is the other half
|
||||||
|
// of the contract: moving the notification off the killer's
|
||||||
|
// goroutine must not lose it. A victim that does read gets
|
||||||
|
// both the KILL and the ERROR line, and then its socket is
|
||||||
|
// closed so its read loop unblocks.
|
||||||
|
func TestDisconnectNotifiesAndClosesVictim(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
serverSide, clientSide := net.Pipe()
|
||||||
|
|
||||||
|
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() {
|
||||||
|
var got []string
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(clientSide)
|
||||||
|
for scanner.Scan() {
|
||||||
|
got = append(got, scanner.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
lines <- got
|
||||||
|
}()
|
||||||
|
|
||||||
|
victim.Disconnect("killed by oper")
|
||||||
|
|
||||||
|
var got []string
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got = <-lines:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("victim socket was never closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
joined := strings.Join(got, "\n")
|
||||||
|
|
||||||
|
if !strings.Contains(joined, "KILL victim") {
|
||||||
|
t.Errorf("missing KILL line, got: %q", joined)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(joined, "ERROR :Closing Link:") {
|
||||||
|
t.Errorf("missing ERROR line, got: %q", joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/neoirc/internal/broker"
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
"sneak.berlin/go/neoirc/internal/config"
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
"sneak.berlin/go/neoirc/internal/db"
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
"sneak.berlin/go/neoirc/internal/service"
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,8 +21,14 @@ func NewTestServer(
|
|||||||
database *db.Database,
|
database *db.Database,
|
||||||
brk *broker.Broker,
|
brk *broker.Broker,
|
||||||
) *Server {
|
) *Server {
|
||||||
|
globs := &globals.Globals{
|
||||||
|
Appname: "neoirc",
|
||||||
|
Version: "test",
|
||||||
|
StartTime: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
svc := service.NewTestService(
|
svc := service.NewTestService(
|
||||||
database, brk, cfg, log,
|
database, brk, cfg, globs, log,
|
||||||
)
|
)
|
||||||
|
|
||||||
return &Server{ //nolint:exhaustruct
|
return &Server{ //nolint:exhaustruct
|
||||||
@@ -47,3 +55,21 @@ func (s *Server) Stop() {
|
|||||||
func (s *Server) Listener() net.Listener {
|
func (s *Server) Listener() net.Listener {
|
||||||
return s.listener
|
return s.listener
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewTestConn wraps an already-established net.Conn in a
|
||||||
|
// Conn so tests can drive connection-level behaviour such
|
||||||
|
// as Disconnect without standing up a whole server.
|
||||||
|
func NewTestConn(
|
||||||
|
log *slog.Logger,
|
||||||
|
cfg *config.Config,
|
||||||
|
tcpConn net.Conn,
|
||||||
|
nick string,
|
||||||
|
) *Conn {
|
||||||
|
conn := newConn(
|
||||||
|
context.Background(), tcpConn, log,
|
||||||
|
nil, nil, cfg, nil,
|
||||||
|
)
|
||||||
|
conn.nick = nick
|
||||||
|
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|||||||
@@ -760,6 +760,336 @@ func TestIntegrationTwoClients(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tier 3 Utility Command Integration Tests ──────────
|
||||||
|
|
||||||
|
// TestIntegrationUserhost verifies the USERHOST command
|
||||||
|
// returns user@host info for connected nicks.
|
||||||
|
func TestIntegrationUserhost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
bob := env.dial(t)
|
||||||
|
bob.register("bob")
|
||||||
|
|
||||||
|
// Query single nick.
|
||||||
|
alice.send("USERHOST bob")
|
||||||
|
|
||||||
|
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.
|
||||||
|
bob.send("USERHOST alice bob")
|
||||||
|
|
||||||
|
bobReply := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 302 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobReply, " 302 ",
|
||||||
|
"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
|
||||||
|
// returns the server version string.
|
||||||
|
func TestIntegrationVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
alice.send("VERSION")
|
||||||
|
|
||||||
|
aliceReply := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 351 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 351 ",
|
||||||
|
"RPL_VERSION",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, "neoirc",
|
||||||
|
"VERSION reply contains server name",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationAdmin verifies the ADMIN command returns
|
||||||
|
// server admin info (256–259 numerics).
|
||||||
|
func TestIntegrationAdmin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
alice.send("ADMIN")
|
||||||
|
|
||||||
|
aliceReply := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 259 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 256 ",
|
||||||
|
"RPL_ADMINME",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 257 ",
|
||||||
|
"RPL_ADMINLOC1",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 258 ",
|
||||||
|
"RPL_ADMINLOC2",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 259 ",
|
||||||
|
"RPL_ADMINEMAIL",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationInfo verifies the INFO command returns
|
||||||
|
// server information (371/374 numerics).
|
||||||
|
func TestIntegrationInfo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
alice.send("INFO")
|
||||||
|
|
||||||
|
aliceReply := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 374 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 371 ",
|
||||||
|
"RPL_INFO",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 374 ",
|
||||||
|
"RPL_ENDOFINFO",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, "neoirc",
|
||||||
|
"INFO reply mentions server name",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationTime verifies the TIME command returns
|
||||||
|
// the server time (391 numeric).
|
||||||
|
func TestIntegrationTime(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
alice.send("TIME")
|
||||||
|
|
||||||
|
aliceReply := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 391 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 391 ",
|
||||||
|
"RPL_TIME",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, "test.irc",
|
||||||
|
"TIME reply includes server name",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationKill verifies the KILL command: oper can
|
||||||
|
// kill a user, non-oper cannot, and — most importantly —
|
||||||
|
// that the victim is actually disconnected rather than
|
||||||
|
// merely announced as having quit.
|
||||||
|
//
|
||||||
|
//nolint:funlen // one KILL scenario asserted end to end
|
||||||
|
func TestIntegrationKill(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnvWithOper(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
bob := env.dial(t)
|
||||||
|
bob.register("bob")
|
||||||
|
|
||||||
|
// Both join a channel so KILL's QUIT is visible.
|
||||||
|
alice.joinAndDrain("#killtest")
|
||||||
|
bob.joinAndDrain("#killtest")
|
||||||
|
|
||||||
|
// Drain alice's view of bob's join.
|
||||||
|
alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "JOIN") &&
|
||||||
|
strings.Contains(l, "bob")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Non-oper KILL should fail.
|
||||||
|
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")
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
assertContains(
|
||||||
|
t, bobLines, "KILL",
|
||||||
|
"victim receives KILL before disconnect",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, bobLines, "ERROR :Closing Link",
|
||||||
|
"victim receives ERROR before disconnect",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, bobLines, "bad behavior",
|
||||||
|
"KILL reason delivered to victim",
|
||||||
|
)
|
||||||
|
|
||||||
|
// alice should see bob's QUIT relay.
|
||||||
|
aliceSeesQuit := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "QUIT") &&
|
||||||
|
strings.Contains(l, "bob")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceSeesQuit, "Killed",
|
||||||
|
"KILL reason in QUIT message",
|
||||||
|
)
|
||||||
|
|
||||||
|
// bob must be gone from the channel member list.
|
||||||
|
alice.send("NAMES #killtest")
|
||||||
|
|
||||||
|
aliceNames := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 366 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceNames, "alice",
|
||||||
|
"alice still in NAMES after killing bob",
|
||||||
|
)
|
||||||
|
assertNotContains(
|
||||||
|
t, aliceNames, "bob",
|
||||||
|
"killed user must not appear in NAMES",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ...nor from WHO.
|
||||||
|
alice.send("WHO #killtest")
|
||||||
|
|
||||||
|
aliceWho := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 315 ")
|
||||||
|
})
|
||||||
|
assertNotContains(
|
||||||
|
t, aliceWho, "bob",
|
||||||
|
"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:
|
||||||
|
// oper can broadcast to +w users.
|
||||||
|
func TestIntegrationWallops(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnvWithOper(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
bob := env.dial(t)
|
||||||
|
bob.register("bob")
|
||||||
|
|
||||||
|
// Non-oper WALLOPS should fail.
|
||||||
|
alice.send("WALLOPS :test broadcast")
|
||||||
|
|
||||||
|
aliceWallopsFail := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 481 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceWallopsFail, " 481 ",
|
||||||
|
"ERR_NOPRIVILEGES for non-oper WALLOPS",
|
||||||
|
)
|
||||||
|
|
||||||
|
// alice becomes oper.
|
||||||
|
alice.send("OPER testoper testpass")
|
||||||
|
alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 381 ")
|
||||||
|
})
|
||||||
|
|
||||||
|
// bob sets +w to receive wallops.
|
||||||
|
bob.send("MODE bob +w")
|
||||||
|
bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 221 ")
|
||||||
|
})
|
||||||
|
|
||||||
|
// alice sends WALLOPS.
|
||||||
|
alice.send("WALLOPS :important announcement")
|
||||||
|
|
||||||
|
// bob (who has +w) should receive it.
|
||||||
|
bobWallops := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(
|
||||||
|
l, "important announcement",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobWallops, "important announcement",
|
||||||
|
"bob receives WALLOPS message",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, bobWallops, "WALLOPS",
|
||||||
|
"message is WALLOPS command",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -911,3 +1241,80 @@ func TestIntegrationThirdClientObserver(t *testing.T) {
|
|||||||
"carol receives trio message",
|
"carol receives trio message",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// assertNoEmptyParam fails if any line contains a doubled
|
||||||
|
// space, which is what FormatMessage emits for an empty
|
||||||
|
// non-trailing parameter. A numeric whose server-name
|
||||||
|
// 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) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnvWithServerName(t, "")
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
alice.send("VERSION")
|
||||||
|
|
||||||
|
versionReply := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 351 ")
|
||||||
|
})
|
||||||
|
assertNoEmptyParam(t, versionReply, "VERSION")
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ func (c *Conn) deliverIRCMessage(
|
|||||||
c.deliverKickMsg(msg, text)
|
c.deliverKickMsg(msg, text)
|
||||||
case command == "INVITE":
|
case command == "INVITE":
|
||||||
c.deliverInviteMsg(msg, text)
|
c.deliverInviteMsg(msg, text)
|
||||||
|
case command == irc.CmdWallops:
|
||||||
|
c.deliverWallops(msg, text)
|
||||||
case command == irc.CmdMode:
|
case command == irc.CmdMode:
|
||||||
c.deliverMode(msg, text)
|
c.deliverMode(msg, text)
|
||||||
case command == irc.CmdPing:
|
case command == irc.CmdPing:
|
||||||
@@ -305,6 +307,18 @@ func (c *Conn) deliverInviteMsg(
|
|||||||
c.sendFromServer("NOTICE", c.nick, text)
|
c.sendFromServer("NOTICE", c.nick, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deliverWallops sends a WALLOPS notification.
|
||||||
|
func (c *Conn) deliverWallops(
|
||||||
|
msg *db.IRCMessage,
|
||||||
|
text string,
|
||||||
|
) {
|
||||||
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
|
|
||||||
|
c.send(FormatMessage(
|
||||||
|
prefix, irc.CmdWallops, text,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
// deliverMode sends a MODE change notification.
|
// deliverMode sends a MODE change notification.
|
||||||
func (c *Conn) deliverMode(
|
func (c *Conn) deliverMode(
|
||||||
msg *db.IRCMessage,
|
msg *db.IRCMessage,
|
||||||
|
|||||||
@@ -38,6 +38,20 @@ type testEnv struct {
|
|||||||
func newTestEnv(t *testing.T) *testEnv {
|
func newTestEnv(t *testing.T) *testEnv {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
return newTestEnvWithServerName(t, "test.irc")
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestEnvWithServerName creates a test environment with
|
||||||
|
// an explicit SERVER_NAME. Passing "" exercises the shipped
|
||||||
|
// default from config.go, under which the server must fall
|
||||||
|
// back to "neoirc" rather than emitting an empty
|
||||||
|
// server-name parameter.
|
||||||
|
func newTestEnvWithServerName(
|
||||||
|
t *testing.T,
|
||||||
|
serverName string,
|
||||||
|
) *testEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
dsn := fmt.Sprintf(
|
dsn := fmt.Sprintf(
|
||||||
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
|
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
|
||||||
t.Name(),
|
t.Name(),
|
||||||
@@ -67,7 +81,7 @@ func newTestEnv(t *testing.T) *testEnv {
|
|||||||
brk := broker.New()
|
brk := broker.New()
|
||||||
|
|
||||||
cfg := &config.Config{ //nolint:exhaustruct
|
cfg := &config.Config{ //nolint:exhaustruct
|
||||||
ServerName: "test.irc",
|
ServerName: serverName,
|
||||||
MOTD: "Welcome to test IRC",
|
MOTD: "Welcome to test IRC",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +126,87 @@ func newTestEnv(t *testing.T) *testEnv {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
@@ -210,6 +305,36 @@ func (tc *testClient) register(nick string) []string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readUntilClosed reads until the peer closes the
|
||||||
|
// connection, returning the lines received first. It
|
||||||
|
// fails the test if the connection is still open when the
|
||||||
|
// read deadline expires, which is what a KILL that never
|
||||||
|
// terminates the victim's socket looks like.
|
||||||
|
func (tc *testClient) readUntilClosed() []string {
|
||||||
|
tc.t.Helper()
|
||||||
|
|
||||||
|
_ = tc.conn.SetReadDeadline(
|
||||||
|
time.Now().Add(testTimeout),
|
||||||
|
)
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
|
||||||
|
for tc.scanner.Scan() {
|
||||||
|
lines = append(lines, tc.scanner.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
err := tc.scanner.Err()
|
||||||
|
if err != nil {
|
||||||
|
tc.t.Fatalf(
|
||||||
|
"expected EOF on victim socket, got %v "+
|
||||||
|
"(lines: %v)",
|
||||||
|
err, lines,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
// assertContains checks that at least one line matches the
|
// assertContains checks that at least one line matches the
|
||||||
// given substring.
|
// given substring.
|
||||||
func assertContains(
|
func assertContains(
|
||||||
@@ -228,6 +353,27 @@ func assertContains(
|
|||||||
t.Errorf("did not find %q in output: %s", substr, description)
|
t.Errorf("did not find %q in output: %s", substr, description)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// assertNotContains checks that no line matches the given
|
||||||
|
// substring.
|
||||||
|
func assertNotContains(
|
||||||
|
t *testing.T,
|
||||||
|
lines []string,
|
||||||
|
substr, description string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.Contains(line, substr) {
|
||||||
|
t.Errorf(
|
||||||
|
"unexpectedly found %q in output: %s",
|
||||||
|
substr, description,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// joinAndDrain joins a channel and reads until
|
// joinAndDrain joins a channel and reads until
|
||||||
// RPL_ENDOFNAMES.
|
// RPL_ENDOFNAMES.
|
||||||
func (tc *testClient) joinAndDrain(channel string) {
|
func (tc *testClient) joinAndDrain(channel string) {
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ type Params struct {
|
|||||||
// It manages routing, middleware, and lifecycle.
|
// It manages routing, middleware, and lifecycle.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
startupTime time.Time
|
startupTime time.Time
|
||||||
exitCode int
|
|
||||||
sentryEnabled bool
|
sentryEnabled bool
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
ctx context.Context //nolint:containedctx // signal handling pattern
|
ctx context.Context //nolint:containedctx // signal handling pattern
|
||||||
@@ -71,7 +70,17 @@ func New(
|
|||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(_ context.Context) error {
|
||||||
srv.startupTime = time.Now()
|
srv.startupTime = time.Now()
|
||||||
go srv.Run() //nolint:contextcheck
|
// Configure, enable Sentry, and build the router
|
||||||
|
// synchronously so that srv.router is fully initialized
|
||||||
|
// before OnStart returns. Any HTTP traffic (including
|
||||||
|
// httptest harnesses that wrap srv as a handler) is
|
||||||
|
// therefore guaranteed to see an initialized router,
|
||||||
|
// eliminating the previous race between SetupRoutes
|
||||||
|
// and ServeHTTP.
|
||||||
|
srv.configure()
|
||||||
|
srv.enableSentry()
|
||||||
|
srv.SetupRoutes()
|
||||||
|
go srv.serve() //nolint:contextcheck
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -83,13 +92,6 @@ func New(
|
|||||||
return srv, nil
|
return srv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the server configuration, Sentry, and begins serving.
|
|
||||||
func (srv *Server) Run() {
|
|
||||||
srv.configure()
|
|
||||||
srv.enableSentry()
|
|
||||||
srv.serve()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeHTTP delegates to the chi router.
|
// ServeHTTP delegates to the chi router.
|
||||||
func (srv *Server) ServeHTTP(
|
func (srv *Server) ServeHTTP(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
@@ -127,7 +129,7 @@ func (srv *Server) enableSentry() {
|
|||||||
srv.sentryEnabled = true
|
srv.sentryEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Server) serve() int {
|
func (srv *Server) serve() {
|
||||||
srv.ctx, srv.cancelFunc = context.WithCancel(
|
srv.ctx, srv.cancelFunc = context.WithCancel(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
)
|
)
|
||||||
@@ -152,8 +154,6 @@ func (srv *Server) serve() int {
|
|||||||
<-srv.ctx.Done()
|
<-srv.ctx.Done()
|
||||||
|
|
||||||
srv.cleanShutdown()
|
srv.cleanShutdown()
|
||||||
|
|
||||||
return srv.exitCode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Server) cleanupForExit() {
|
func (srv *Server) cleanupForExit() {
|
||||||
@@ -161,8 +161,6 @@ func (srv *Server) cleanupForExit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Server) cleanShutdown() {
|
func (srv *Server) cleanShutdown() {
|
||||||
srv.exitCode = 0
|
|
||||||
|
|
||||||
ctxShutdown, shutdownCancel := context.WithTimeout(
|
ctxShutdown, shutdownCancel := context.WithTimeout(
|
||||||
context.Background(), shutdownTimeout,
|
context.Background(), shutdownTimeout,
|
||||||
)
|
)
|
||||||
@@ -202,8 +200,6 @@ func (srv *Server) serveUntilShutdown() {
|
|||||||
Handler: srv,
|
Handler: srv,
|
||||||
}
|
}
|
||||||
|
|
||||||
srv.SetupRoutes()
|
|
||||||
|
|
||||||
srv.log.Info(
|
srv.log.Info(
|
||||||
"http begin listen", "listenaddr", listenAddr,
|
"http begin listen", "listenaddr", listenAddr,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/neoirc/internal/broker"
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
"sneak.berlin/go/neoirc/internal/config"
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
"sneak.berlin/go/neoirc/internal/db"
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
"sneak.berlin/go/neoirc/internal/logger"
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
"sneak.berlin/go/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
@@ -27,23 +30,41 @@ type Params struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
Database *db.Database
|
Database *db.Database
|
||||||
Broker *broker.Broker
|
Broker *broker.Broker
|
||||||
|
Globals *globals.Globals
|
||||||
|
}
|
||||||
|
|
||||||
|
// WireConn is a live client connection that a transport
|
||||||
|
// registers with the service so that commands such as KILL
|
||||||
|
// 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 {
|
||||||
|
// Disconnect terminates the connection, telling the
|
||||||
|
// client why before closing the socket.
|
||||||
|
Disconnect(reason string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service provides shared business logic for IRC commands.
|
// Service provides shared business logic for IRC commands.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
db *db.Database
|
db *db.Database
|
||||||
broker *broker.Broker
|
broker *broker.Broker
|
||||||
config *config.Config
|
config *config.Config
|
||||||
log *slog.Logger
|
globals *globals.Globals
|
||||||
|
log *slog.Logger
|
||||||
|
|
||||||
|
wireMu sync.Mutex
|
||||||
|
wireConns map[int64]WireConn
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Service.
|
// New creates a new Service.
|
||||||
func New(params Params) *Service {
|
func New(params Params) *Service {
|
||||||
return &Service{
|
return &Service{ //nolint:exhaustruct // mutex zero value
|
||||||
db: params.Database,
|
db: params.Database,
|
||||||
broker: params.Broker,
|
broker: params.Broker,
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
log: params.Logger.Get(),
|
globals: params.Globals,
|
||||||
|
log: params.Logger.Get(),
|
||||||
|
wireConns: make(map[int64]WireConn),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,13 +74,105 @@ func NewTestService(
|
|||||||
database *db.Database,
|
database *db.Database,
|
||||||
brk *broker.Broker,
|
brk *broker.Broker,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
|
globs *globals.Globals,
|
||||||
log *slog.Logger,
|
log *slog.Logger,
|
||||||
) *Service {
|
) *Service {
|
||||||
return &Service{
|
return &Service{ //nolint:exhaustruct // mutex zero value
|
||||||
db: database,
|
db: database,
|
||||||
broker: brk,
|
broker: brk,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
log: log,
|
globals: globs,
|
||||||
|
log: log,
|
||||||
|
wireConns: make(map[int64]WireConn),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerVersion returns the canonical server version string
|
||||||
|
// used by every transport, e.g. "neoirc-1.2.3". Both the
|
||||||
|
// 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 {
|
||||||
|
name := "neoirc"
|
||||||
|
ver := "dev"
|
||||||
|
|
||||||
|
if s.globals != nil {
|
||||||
|
if s.globals.Appname != "" {
|
||||||
|
name = s.globals.Appname
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.globals.Version != "" {
|
||||||
|
ver = s.globals.Version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return name + "-" + ver
|
||||||
|
}
|
||||||
|
|
||||||
|
// InfoLines returns the RPL_INFO body. Both transports
|
||||||
|
// send exactly these lines so that INFO does not diverge
|
||||||
|
// between the wire protocol and the HTTP API.
|
||||||
|
func (s *Service) InfoLines() []string {
|
||||||
|
started := "unknown"
|
||||||
|
if s.globals != nil && !s.globals.StartTime.IsZero() {
|
||||||
|
started = s.globals.StartTime.Format(time.RFC1123)
|
||||||
|
}
|
||||||
|
|
||||||
|
return []string{
|
||||||
|
"neoirc — IRC semantics over HTTP",
|
||||||
|
"Version: " + s.ServerVersion(),
|
||||||
|
"Written in Go",
|
||||||
|
"Started: " + started,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterWireConn associates a live wire connection with
|
||||||
|
// its session ID so that KillSession can reach it.
|
||||||
|
func (s *Service) RegisterWireConn(
|
||||||
|
sessionID int64,
|
||||||
|
conn WireConn,
|
||||||
|
) {
|
||||||
|
s.wireMu.Lock()
|
||||||
|
defer s.wireMu.Unlock()
|
||||||
|
|
||||||
|
s.wireConns[sessionID] = conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnregisterWireConn removes the association created by
|
||||||
|
// RegisterWireConn. It is a no-op if the session has
|
||||||
|
// already been rebound to a different connection.
|
||||||
|
func (s *Service) UnregisterWireConn(
|
||||||
|
sessionID int64,
|
||||||
|
conn WireConn,
|
||||||
|
) {
|
||||||
|
s.wireMu.Lock()
|
||||||
|
defer s.wireMu.Unlock()
|
||||||
|
|
||||||
|
if s.wireConns[sessionID] == conn {
|
||||||
|
delete(s.wireConns, sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// KillSession forcibly removes a user from the server: the
|
||||||
|
// victim's channel peers are told via QUIT, the victim's
|
||||||
|
// session is deleted, and any live wire connection it holds
|
||||||
|
// is disconnected. Both the IRC KILL command and the HTTP
|
||||||
|
// KILL endpoint route through here so the two transports
|
||||||
|
// cannot diverge.
|
||||||
|
func (s *Service) KillSession(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
nick, reason string,
|
||||||
|
) {
|
||||||
|
s.BroadcastQuit(ctx, sessionID, nick, reason)
|
||||||
|
|
||||||
|
// A session with no registered wire connection (an
|
||||||
|
// HTTP-only client) has nothing left to disconnect.
|
||||||
|
s.wireMu.Lock()
|
||||||
|
conn := s.wireConns[sessionID]
|
||||||
|
s.wireMu.Unlock()
|
||||||
|
|
||||||
|
if conn != nil {
|
||||||
|
conn.Disconnect(reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -791,6 +904,201 @@ func (s *Service) QueryChannelMode(
|
|||||||
return modes + modeParams
|
return modes + modeParams
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QueryUserMode returns the current user mode string for
|
||||||
|
// the given session (e.g. "+ow", "+w", "+"). A database
|
||||||
|
// 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(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
) (string, error) {
|
||||||
|
modes := "+"
|
||||||
|
|
||||||
|
isOper, err := s.db.IsSessionOper(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"query oper flag: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isOper {
|
||||||
|
modes += "o"
|
||||||
|
}
|
||||||
|
|
||||||
|
isWallops, err := s.db.IsSessionWallops(
|
||||||
|
ctx, sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"query wallops flag: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isWallops {
|
||||||
|
modes += "w"
|
||||||
|
}
|
||||||
|
|
||||||
|
return modes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// userModeOp is a single parsed user-mode change collected
|
||||||
|
// by parseUserModeString before any DB writes happen.
|
||||||
|
type userModeOp struct {
|
||||||
|
char rune
|
||||||
|
adding bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
modeStr string,
|
||||||
|
) (string, error) {
|
||||||
|
ops, err := parseUserModeString(modeStr)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
wallops, oper, err := collapseUserModeOps(ops)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.SetSessionUserModes(
|
||||||
|
ctx, sessionID, wallops, oper,
|
||||||
|
); err != nil {
|
||||||
|
s.log.Error(
|
||||||
|
"apply user modes failed", "error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return "", fmt.Errorf("apply user modes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.QueryUserMode(ctx, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseUserModeString validates and parses a user-mode
|
||||||
|
// string into a list of operations. The string must begin
|
||||||
|
// with '+' or '-'; subsequent '+' / '-' characters flip the
|
||||||
|
// active sign, and letters between them are applied with
|
||||||
|
// the current sign. Every letter must be a recognized user
|
||||||
|
// mode for this server, and '+o' is never allowed via MODE
|
||||||
|
// (use OPER to become operator). If any character is
|
||||||
|
// invalid, no operations are returned and an IRCError with
|
||||||
|
// ERR_UMODEUNKNOWNFLAG (501) is returned.
|
||||||
|
func parseUserModeString(
|
||||||
|
modeStr string,
|
||||||
|
) ([]userModeOp, error) {
|
||||||
|
unknownFlag := &IRCError{
|
||||||
|
Code: irc.ErrUmodeUnknownFlag,
|
||||||
|
Params: nil,
|
||||||
|
Message: "Unknown MODE flag",
|
||||||
|
}
|
||||||
|
|
||||||
|
if modeStr == "" {
|
||||||
|
return nil, unknownFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
first := modeStr[0]
|
||||||
|
if first != '+' && first != '-' {
|
||||||
|
return nil, unknownFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
ops := make([]userModeOp, 0, len(modeStr)-1)
|
||||||
|
adding := true
|
||||||
|
|
||||||
|
for _, modeChar := range modeStr {
|
||||||
|
switch modeChar {
|
||||||
|
case '+':
|
||||||
|
adding = true
|
||||||
|
case '-':
|
||||||
|
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':
|
||||||
|
val := modeOp.adding
|
||||||
|
wallops = &val
|
||||||
|
case 'o':
|
||||||
|
if modeOp.adding {
|
||||||
|
return nil, nil, unknownFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
val := false
|
||||||
|
oper = &val
|
||||||
|
default:
|
||||||
|
return nil, nil, unknownFlag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return wallops, oper, 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(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"go.uber.org/fx/fxtest"
|
"go.uber.org/fx/fxtest"
|
||||||
@@ -55,9 +56,10 @@ func newTestEnv(t *testing.T) *testEnv {
|
|||||||
app := fxtest.New(t,
|
app := fxtest.New(t,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
func() *globals.Globals {
|
func() *globals.Globals {
|
||||||
return &globals.Globals{ //nolint:exhaustruct
|
return &globals.Globals{
|
||||||
Appname: "neoirc-test",
|
Appname: "neoirc-test",
|
||||||
Version: "test",
|
Version: "test",
|
||||||
|
StartTime: time.Now(),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -363,3 +365,450 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
|
|||||||
t.Errorf("operator should be able to send in moderated channel: %v", err)
|
t.Errorf("operator should be able to send in moderated channel: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQueryUserMode(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sid := createSession(ctx, t, env.db, "alice")
|
||||||
|
|
||||||
|
// Fresh session has no modes.
|
||||||
|
modes, err := env.svc.QueryUserMode(ctx, sid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query user mode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if modes != "+" {
|
||||||
|
t.Errorf("expected +, got %s", modes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set wallops.
|
||||||
|
_ = env.db.SetSessionWallops(ctx, sid, true)
|
||||||
|
|
||||||
|
modes, err = env.svc.QueryUserMode(ctx, sid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query user mode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if modes != "+w" {
|
||||||
|
t.Errorf("expected +w, got %s", modes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set oper.
|
||||||
|
_ = env.db.SetSessionOper(ctx, sid, true)
|
||||||
|
|
||||||
|
modes, err = env.svc.QueryUserMode(ctx, sid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query user mode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if modes != "+ow" {
|
||||||
|
t.Errorf("expected +ow, got %s", modes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyUserModeCaseState is the subset of session user-mode
|
||||||
|
// state the rigorous TestApplyUserMode suite asserts on. It
|
||||||
|
// mirrors the columns (oper, wallops) that the parser is
|
||||||
|
// permitted to mutate.
|
||||||
|
type applyUserModeCaseState struct {
|
||||||
|
oper bool
|
||||||
|
wallops bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyUserModeCase describes one rigor-suite case for
|
||||||
|
// Service.ApplyUserMode: the pre-call DB state, the mode
|
||||||
|
// string input, and the expected post-call observable state
|
||||||
|
// (mode string on success, IRC error code on rejection, and
|
||||||
|
// persisted session state either way).
|
||||||
|
type applyUserModeCase struct {
|
||||||
|
name string
|
||||||
|
initialState applyUserModeCaseState
|
||||||
|
modeStr string
|
||||||
|
wantModes string
|
||||||
|
wantErr bool
|
||||||
|
wantErrCode irc.IRCMessageType
|
||||||
|
wantState applyUserModeCaseState
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyUserModeCases returns every case listed in sneak's
|
||||||
|
// review of PR #96 plus a few adjacent ones. Split across
|
||||||
|
// helpers by category so each stays under funlen.
|
||||||
|
func applyUserModeCases() []applyUserModeCase {
|
||||||
|
cases := applyUserModeHappyPathCases()
|
||||||
|
cases = append(cases, applyUserModeSignTransitionCases()...)
|
||||||
|
cases = append(cases, applyUserModeMalformedCases()...)
|
||||||
|
cases = append(cases, applyUserModeUnknownLetterCases()...)
|
||||||
|
|
||||||
|
return cases
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyUserModeHappyPathCases covers valid single-char and
|
||||||
|
// multi-char-without-sign-transition mode operations.
|
||||||
|
func applyUserModeHappyPathCases() []applyUserModeCase {
|
||||||
|
return []applyUserModeCase{
|
||||||
|
{
|
||||||
|
name: "+w from empty",
|
||||||
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
modeStr: "+w",
|
||||||
|
wantModes: "+w",
|
||||||
|
wantErr: false,
|
||||||
|
wantErrCode: 0,
|
||||||
|
wantState: applyUserModeCaseState{oper: false, wallops: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "-w from +w",
|
||||||
|
initialState: applyUserModeCaseState{oper: false, wallops: true},
|
||||||
|
modeStr: "-w",
|
||||||
|
wantModes: "+",
|
||||||
|
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) {
|
||||||
|
for _, testCase := range applyUserModeCases() {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
runApplyUserModeCase(t, testCase)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runApplyUserModeCase executes one applyUserModeCase: seed
|
||||||
|
// 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,
|
||||||
|
database *db.Database,
|
||||||
|
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,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if testCase.wantErr {
|
||||||
|
verifyApplyUserModeError(t, testCase, err)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != testCase.wantModes {
|
||||||
|
t.Errorf(
|
||||||
|
"modes: want %q got %q",
|
||||||
|
testCase.wantModes, result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyApplyUserModeState asserts the post-call persisted
|
||||||
|
// 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,
|
||||||
|
t *testing.T,
|
||||||
|
database *db.Database,
|
||||||
|
sid int64,
|
||||||
|
wantState applyUserModeCaseState,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
gotOper, err := database.IsSessionOper(ctx, sid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read oper: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotWallops, err := database.IsSessionWallops(ctx, sid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read wallops: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotOper != wantState.oper {
|
||||||
|
t.Errorf(
|
||||||
|
"oper: want %v got %v",
|
||||||
|
wantState.oper, gotOper,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotWallops != wantState.wallops {
|
||||||
|
t.Errorf(
|
||||||
|
"wallops: want %v got %v",
|
||||||
|
wantState.wallops, gotWallops,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,26 +2,33 @@ package irc
|
|||||||
|
|
||||||
// IRC command names (RFC 1459 / RFC 2812).
|
// IRC command names (RFC 1459 / RFC 2812).
|
||||||
const (
|
const (
|
||||||
CmdAway = "AWAY"
|
CmdAdmin = "ADMIN"
|
||||||
CmdInvite = "INVITE"
|
CmdAway = "AWAY"
|
||||||
CmdJoin = "JOIN"
|
CmdInfo = "INFO"
|
||||||
CmdKick = "KICK"
|
CmdInvite = "INVITE"
|
||||||
CmdList = "LIST"
|
CmdJoin = "JOIN"
|
||||||
CmdLusers = "LUSERS"
|
CmdKick = "KICK"
|
||||||
CmdMode = "MODE"
|
CmdKill = "KILL"
|
||||||
CmdMotd = "MOTD"
|
CmdList = "LIST"
|
||||||
CmdNames = "NAMES"
|
CmdLusers = "LUSERS"
|
||||||
CmdNick = "NICK"
|
CmdMode = "MODE"
|
||||||
CmdNotice = "NOTICE"
|
CmdMotd = "MOTD"
|
||||||
CmdOper = "OPER"
|
CmdNames = "NAMES"
|
||||||
CmdPass = "PASS"
|
CmdNick = "NICK"
|
||||||
CmdPart = "PART"
|
CmdNotice = "NOTICE"
|
||||||
CmdPing = "PING"
|
CmdOper = "OPER"
|
||||||
CmdPong = "PONG"
|
CmdPass = "PASS"
|
||||||
CmdPrivmsg = "PRIVMSG"
|
CmdPart = "PART"
|
||||||
CmdQuit = "QUIT"
|
CmdPing = "PING"
|
||||||
CmdTopic = "TOPIC"
|
CmdPong = "PONG"
|
||||||
CmdUser = "USER"
|
CmdPrivmsg = "PRIVMSG"
|
||||||
CmdWho = "WHO"
|
CmdQuit = "QUIT"
|
||||||
CmdWhois = "WHOIS"
|
CmdTime = "TIME"
|
||||||
|
CmdTopic = "TOPIC"
|
||||||
|
CmdUser = "USER"
|
||||||
|
CmdUserhost = "USERHOST"
|
||||||
|
CmdVersion = "VERSION"
|
||||||
|
CmdWallops = "WALLOPS"
|
||||||
|
CmdWho = "WHO"
|
||||||
|
CmdWhois = "WHOIS"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user