USERHOST, VERSION, ADMIN, INFO, TIME, KILL and WALLOPS work on the HTTP API and on the IRC listener. The USERHOST reply, the VERSION, ADMIN and INFO text, KILL and WALLOPS are each one service function that both transports call. User mode +w selects who receives WALLOPS. MODE on your own nick queries or changes your user modes through one parser on both transports: the whole mode string is checked before anything is stored, and both flags are stored in one transaction. KILL closes the victim's IRC connection after sending it KILL and ERROR, without the operator waiting on it. The sessions table gains is_wallops in 001_initial.sql, as the pre-1.0 rule in REPO_POLICIES.md asks. Model: opus-5-5 Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
This commit was merged in pull request #96.
This commit is contained in:
+136
-39
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
// sendIRCError maps a service.IRCError to an IRC numeric
|
||||
// reply on the wire.
|
||||
// reply on the wire, and logs any other error.
|
||||
func (c *Conn) sendIRCError(err error) {
|
||||
var ircErr *service.IRCError
|
||||
if errors.As(err, &ircErr) {
|
||||
@@ -21,7 +21,11 @@ func (c *Conn) sendIRCError(err error) {
|
||||
args = append(args, ircErr.Params...)
|
||||
args = append(args, ircErr.Message)
|
||||
c.sendNumeric(ircErr.Code, args...)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.log.Error("command failed", "error", err)
|
||||
}
|
||||
|
||||
// handleCAP silently acknowledges CAP negotiation.
|
||||
@@ -345,7 +349,10 @@ func (c *Conn) handleQuit(msg *Message) {
|
||||
|
||||
c.send("ERROR :Closing Link: " + c.hostname +
|
||||
" (Quit: " + reason + ")")
|
||||
|
||||
c.mu.Lock()
|
||||
c.closed = true
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// handleTopic gets or sets a channel topic via the shared
|
||||
@@ -427,7 +434,7 @@ func (c *Conn) handleMode(
|
||||
if strings.HasPrefix(target, "#") {
|
||||
c.handleChannelMode(ctx, msg)
|
||||
} else {
|
||||
c.handleUserMode(msg)
|
||||
c.handleUserMode(ctx, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -686,11 +693,14 @@ func (c *Conn) applyChannelModes(
|
||||
}
|
||||
}
|
||||
|
||||
// handleUserMode handles MODE for users.
|
||||
func (c *Conn) handleUserMode(msg *Message) {
|
||||
target := msg.Params[0]
|
||||
|
||||
if !strings.EqualFold(target, c.nick) {
|
||||
// handleUserMode handles MODE for a nick: a query without a
|
||||
// mode string, otherwise a change. Only the client's own
|
||||
// nick, in any letter case, is allowed.
|
||||
func (c *Conn) handleUserMode(
|
||||
ctx context.Context,
|
||||
msg *Message,
|
||||
) {
|
||||
if !strings.EqualFold(msg.Params[0], c.currentNick()) {
|
||||
c.sendNumeric(
|
||||
irc.ErrUsersDoNotMatch,
|
||||
"Can't change mode for other users",
|
||||
@@ -699,8 +709,26 @@ func (c *Conn) handleUserMode(msg *Message) {
|
||||
return
|
||||
}
|
||||
|
||||
// We don't support user modes beyond the basics.
|
||||
c.sendNumeric(irc.RplUmodeIs, "+")
|
||||
var (
|
||||
modes string
|
||||
err error
|
||||
)
|
||||
|
||||
if len(msg.Params) == 1 {
|
||||
modes, err = c.svc.QueryUserMode(ctx, c.sessionID)
|
||||
} else {
|
||||
modes, err = c.svc.ApplyUserMode(
|
||||
ctx, c.sessionID, msg.Params[1],
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.sendIRCError(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.sendNumeric(irc.RplUmodeIs, modes)
|
||||
}
|
||||
|
||||
// handleNames replies with channel member list.
|
||||
@@ -1248,47 +1276,116 @@ func (c *Conn) handleInvite(
|
||||
)
|
||||
}
|
||||
|
||||
// handleUserhost replies with USERHOST info.
|
||||
// handleUserhost replies with user@host for each of up to
|
||||
// five nicks.
|
||||
func (c *Conn) handleUserhost(
|
||||
ctx context.Context,
|
||||
msg *Message,
|
||||
) {
|
||||
if len(msg.Params) < 1 {
|
||||
c.sendNumeric(
|
||||
irc.ErrNeedMoreParams,
|
||||
irc.CmdUserhost, "Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
replies := make([]string, 0, len(msg.Params))
|
||||
reply, err := c.svc.UserhostReply(
|
||||
ctx, msg.Params, c.serverSfx,
|
||||
)
|
||||
if err != nil {
|
||||
c.sendIRCError(err)
|
||||
|
||||
for _, nick := range msg.Params {
|
||||
sid, err := c.database.GetSessionByNick(ctx, nick)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
hostInfo, _ := c.database.GetSessionHostInfo(
|
||||
ctx, sid,
|
||||
)
|
||||
|
||||
host := "*"
|
||||
if hostInfo != nil {
|
||||
host = hostInfo.Hostname
|
||||
}
|
||||
|
||||
isOper, _ := c.database.IsSessionOper(ctx, sid)
|
||||
|
||||
operStar := ""
|
||||
if isOper {
|
||||
operStar = "*"
|
||||
}
|
||||
|
||||
replies = append(
|
||||
replies,
|
||||
nick+operStar+"=+"+nick+"@"+host,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
c.sendNumeric(irc.RplUserHost, reply)
|
||||
}
|
||||
|
||||
// handleVersion replies with the server version.
|
||||
func (c *Conn) handleVersion() {
|
||||
c.sendNumeric(
|
||||
irc.RplUserHost,
|
||||
strings.Join(replies, " "),
|
||||
irc.RplVersion,
|
||||
c.svc.ServerVersion()+".", c.serverSfx, "",
|
||||
)
|
||||
}
|
||||
|
||||
// handleAdmin replies with the server's admin info.
|
||||
func (c *Conn) handleAdmin() {
|
||||
location1, location2, email := c.svc.AdminLines(c.serverSfx)
|
||||
|
||||
c.sendNumeric(
|
||||
irc.RplAdminMe, c.serverSfx, "Administrative info",
|
||||
)
|
||||
c.sendNumeric(irc.RplAdminLoc1, location1)
|
||||
c.sendNumeric(irc.RplAdminLoc2, location2)
|
||||
c.sendNumeric(irc.RplAdminEmail, email)
|
||||
}
|
||||
|
||||
// handleInfo replies with the server's 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 local time.
|
||||
func (c *Conn) handleTime() {
|
||||
c.sendNumeric(
|
||||
irc.RplTime,
|
||||
c.serverSfx, time.Now().Format(time.RFC1123),
|
||||
)
|
||||
}
|
||||
|
||||
// handleKill handles KILL <nick> [<reason>].
|
||||
func (c *Conn) handleKill(
|
||||
ctx context.Context,
|
||||
msg *Message,
|
||||
) {
|
||||
if len(msg.Params) < 1 {
|
||||
c.sendNumeric(
|
||||
irc.ErrNeedMoreParams,
|
||||
irc.CmdKill, "Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
reason := ""
|
||||
if len(msg.Params) > 1 {
|
||||
reason = msg.Params[1]
|
||||
}
|
||||
|
||||
err := c.svc.KillUser(
|
||||
ctx, c.sessionID, c.currentNick(),
|
||||
msg.Params[0], reason,
|
||||
)
|
||||
if err != nil {
|
||||
c.sendIRCError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleWallops handles WALLOPS <message>.
|
||||
func (c *Conn) handleWallops(
|
||||
ctx context.Context,
|
||||
msg *Message,
|
||||
) {
|
||||
if len(msg.Params) < 1 {
|
||||
c.sendNumeric(
|
||||
irc.ErrNeedMoreParams,
|
||||
irc.CmdWallops, "Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := c.svc.SendWallops(
|
||||
ctx, c.sessionID, c.currentNick(), msg.Params[0],
|
||||
)
|
||||
if err != nil {
|
||||
c.sendIRCError(err)
|
||||
}
|
||||
}
|
||||
|
||||
+112
-6
@@ -22,6 +22,7 @@ const (
|
||||
maxLineLen = 512
|
||||
readTimeout = 5 * time.Minute
|
||||
writeTimeout = 30 * time.Second
|
||||
killWriteWindow = 2 * time.Second
|
||||
dnsTimeout = 3 * time.Second
|
||||
pollInterval = 100 * time.Millisecond
|
||||
pingInterval = 90 * time.Second
|
||||
@@ -46,6 +47,11 @@ type Conn struct {
|
||||
serverSfx string
|
||||
commands map[string]cmdHandler
|
||||
|
||||
// writeMu serializes writes to conn, which come from
|
||||
// serve(), from the relay goroutine and, for KILL, from
|
||||
// Disconnect.
|
||||
writeMu sync.Mutex
|
||||
|
||||
mu sync.Mutex
|
||||
nick string
|
||||
username string
|
||||
@@ -62,6 +68,7 @@ type Conn struct {
|
||||
|
||||
lastQueueID int64
|
||||
closed bool
|
||||
killed bool
|
||||
}
|
||||
|
||||
func newConn(
|
||||
@@ -97,6 +104,57 @@ func newConn(
|
||||
return conn
|
||||
}
|
||||
|
||||
// Disconnect ends the connection for an operator KILL from
|
||||
// either transport: the victim is sent KILL and ERROR, then
|
||||
// its socket is closed, which ends serve() and with it the
|
||||
// relay goroutine. It is called from the killer's goroutine
|
||||
// and does not wait for the writes, so a victim that has
|
||||
// stopped reading cannot stall the killer.
|
||||
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 = "*"
|
||||
}
|
||||
|
||||
go func() {
|
||||
c.notifyKilledAndClose(nick, host, reason)
|
||||
}()
|
||||
}
|
||||
|
||||
// notifyKilledAndClose sends a killed victim the KILL and
|
||||
// ERROR lines, each bounded by killWriteWindow, and then
|
||||
// closes its socket.
|
||||
func (c *Conn) notifyKilledAndClose(
|
||||
nick, host, reason string,
|
||||
) {
|
||||
defer func() { _ = c.conn.Close() }()
|
||||
|
||||
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
|
||||
// to handler functions.
|
||||
func (c *Conn) buildCommandMap() map[string]cmdHandler {
|
||||
@@ -129,7 +187,13 @@ func (c *Conn) buildCommandMap() map[string]cmdHandler {
|
||||
"CAP": func(_ context.Context, msg *Message) {
|
||||
c.handleCAP(msg)
|
||||
},
|
||||
"USERHOST": c.handleUserhost,
|
||||
irc.CmdUserhost: 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.handleKill,
|
||||
irc.CmdWallops: c.handleWallops,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +244,7 @@ func (c *Conn) serve(ctx context.Context) {
|
||||
|
||||
c.handleMessage(ctx, msg)
|
||||
|
||||
if c.closed {
|
||||
if c.isClosed() {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -189,24 +253,60 @@ func (c *Conn) serve(ctx context.Context) {
|
||||
func (c *Conn) cleanup(ctx context.Context) {
|
||||
c.mu.Lock()
|
||||
wasRegistered := c.registered
|
||||
wasKilled := c.killed
|
||||
sessID := c.sessionID
|
||||
nick := c.nick
|
||||
c.closed = true
|
||||
c.mu.Unlock()
|
||||
|
||||
if wasRegistered && sessID > 0 {
|
||||
c.svc.BroadcastQuit(
|
||||
ctx, sessID, nick, "Connection closed",
|
||||
)
|
||||
c.svc.UnregisterWireConn(sessID, c)
|
||||
|
||||
// KILL has already sent the QUIT and deleted the
|
||||
// session.
|
||||
if !wasKilled {
|
||||
c.svc.BroadcastQuit(
|
||||
ctx, sessID, nick, "Connection closed",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
c.conn.Close() //nolint:errcheck,gosec
|
||||
}
|
||||
|
||||
// isClosed reports whether QUIT or KILL has ended the
|
||||
// connection.
|
||||
func (c *Conn) isClosed() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
return c.closed
|
||||
}
|
||||
|
||||
// currentNick returns c.nick, read under c.mu.
|
||||
func (c *Conn) currentNick() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
return c.nick
|
||||
}
|
||||
|
||||
// send writes a formatted IRC line to the connection.
|
||||
func (c *Conn) send(line string) {
|
||||
c.sendWithin(writeTimeout, line)
|
||||
}
|
||||
|
||||
// sendWithin writes a formatted IRC line to the connection,
|
||||
// giving up after timeout.
|
||||
func (c *Conn) sendWithin(
|
||||
timeout time.Duration,
|
||||
line string,
|
||||
) {
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
|
||||
_ = c.conn.SetWriteDeadline(
|
||||
time.Now().Add(writeTimeout),
|
||||
time.Now().Add(timeout),
|
||||
)
|
||||
|
||||
_, _ = fmt.Fprintf(c.conn, "%s\r\n", line)
|
||||
@@ -387,7 +487,10 @@ func (c *Conn) completeRegistration(ctx context.Context) {
|
||||
"failed to create session", "error", err,
|
||||
)
|
||||
c.send("ERROR :Internal server error")
|
||||
|
||||
c.mu.Lock()
|
||||
c.closed = true
|
||||
c.mu.Unlock()
|
||||
|
||||
return
|
||||
}
|
||||
@@ -398,6 +501,9 @@ func (c *Conn) completeRegistration(ctx context.Context) {
|
||||
c.registered = true
|
||||
c.mu.Unlock()
|
||||
|
||||
// So that KILL, from either transport, can close it.
|
||||
c.svc.RegisterWireConn(sessionID, c)
|
||||
|
||||
// If PASS was provided before registration, set the
|
||||
// session password.
|
||||
if c.passWord != "" && len(c.passWord) >= minPasswordLen {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package ircserver_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/ircserver"
|
||||
)
|
||||
|
||||
// newKillVictim returns a Conn for the nick victim whose
|
||||
// socket is one end of a net.Pipe, and the other end, from
|
||||
// which the test reads what the victim is sent. A write to
|
||||
// the pipe blocks until the other end reads.
|
||||
func newKillVictim(t *testing.T) (*ircserver.Conn, net.Conn) {
|
||||
t.Helper()
|
||||
|
||||
serverSide, clientSide := net.Pipe()
|
||||
|
||||
t.Cleanup(func() { _ = clientSide.Close() })
|
||||
|
||||
log := slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
&slog.HandlerOptions{Level: slog.LevelError},
|
||||
))
|
||||
cfg := &config.Config{ServerName: testServerName}
|
||||
|
||||
return ircserver.NewTestConn(
|
||||
log, cfg, serverSide, "victim",
|
||||
), clientSide
|
||||
}
|
||||
|
||||
// TestDisconnectDoesNotBlockOnUnresponsiveVictim checks
|
||||
// that Disconnect returns although the victim never reads,
|
||||
// so a KILL cannot stall the operator who sent it. The
|
||||
// limit is well below one KillWriteWindow, so a Disconnect
|
||||
// that waits on any write to the victim fails.
|
||||
func TestDisconnectDoesNotBlockOnUnresponsiveVictim(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
victim, _ := newKillVictim(t)
|
||||
returned := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
victim.Disconnect("killed by oper")
|
||||
close(returned)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-returned:
|
||||
case <-time.After(ircserver.KillWriteWindow / 4):
|
||||
t.Fatal("Disconnect waited for the victim to read")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisconnectNotifiesAndClosesVictim checks that a
|
||||
// victim that reads is sent KILL and ERROR, and that its
|
||||
// connection is then closed.
|
||||
func TestDisconnectNotifiesAndClosesVictim(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
victim, clientSide := newKillVictim(t)
|
||||
received := make(chan []string, 1)
|
||||
|
||||
go func() {
|
||||
var lines []string
|
||||
|
||||
scanner := bufio.NewScanner(clientSide)
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
|
||||
received <- lines
|
||||
}()
|
||||
|
||||
victim.Disconnect("killed by oper")
|
||||
|
||||
var lines []string
|
||||
|
||||
select {
|
||||
case lines = <-received:
|
||||
case <-time.After(testTimeout):
|
||||
t.Fatal("the victim's connection was not closed")
|
||||
}
|
||||
|
||||
joined := strings.Join(lines, "\n")
|
||||
|
||||
if !strings.Contains(joined, "KILL victim :killed by oper") {
|
||||
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"
|
||||
"log/slog"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/broker"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
"sneak.berlin/go/neoirc/internal/globals"
|
||||
"sneak.berlin/go/neoirc/internal/service"
|
||||
)
|
||||
|
||||
@@ -19,8 +21,14 @@ func NewTestServer(
|
||||
database *db.Database,
|
||||
brk *broker.Broker,
|
||||
) *Server {
|
||||
globs := &globals.Globals{
|
||||
Appname: "neoirc",
|
||||
Version: "test",
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
|
||||
svc := service.NewTestService(
|
||||
database, brk, cfg, log,
|
||||
database, brk, cfg, globs, log,
|
||||
)
|
||||
|
||||
return &Server{ //nolint:exhaustruct
|
||||
@@ -47,3 +55,24 @@ func (s *Server) Stop() {
|
||||
func (s *Server) Listener() net.Listener {
|
||||
return s.listener
|
||||
}
|
||||
|
||||
// KillWriteWindow is how long Disconnect allows each write
|
||||
// to a killed victim.
|
||||
const KillWriteWindow = killWriteWindow
|
||||
|
||||
// NewTestConn returns a Conn for nick on tcpConn, without a
|
||||
// server, database or service behind it.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
)
|
||||
|
||||
// TestIntegrationTwoClients is a comprehensive integration
|
||||
@@ -762,6 +764,371 @@ 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")
|
||||
|
||||
bob.send("AWAY :lunch")
|
||||
bob.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 306 ")
|
||||
})
|
||||
|
||||
for _, step := range []struct {
|
||||
line, want string
|
||||
}{
|
||||
{"USERHOST alice", " 302 alice alice=+alice@"},
|
||||
{"USERHOST alice bob", " :alice=+alice@"},
|
||||
{"USERHOST alice bob", " bob=-bob@"},
|
||||
{"USERHOST nobody", " 302 alice :"},
|
||||
{"USERHOST", " 461 alice USERHOST :"},
|
||||
} {
|
||||
alice.send(step.line)
|
||||
|
||||
reply := alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 302 ") ||
|
||||
strings.Contains(l, " 461 ")
|
||||
})
|
||||
assertContains(t, reply, step.want, step.line)
|
||||
}
|
||||
}
|
||||
|
||||
// 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, testServerName,
|
||||
"TIME reply includes server name",
|
||||
)
|
||||
}
|
||||
|
||||
// becomeOper sends OPER with newTestEnvWithOper's
|
||||
// credentials and waits for RPL_YOUREOPER.
|
||||
func (tc *testClient) becomeOper() {
|
||||
tc.t.Helper()
|
||||
|
||||
tc.send("OPER testoper testpass")
|
||||
tc.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 381 ")
|
||||
})
|
||||
}
|
||||
|
||||
// TestIntegrationKillRefused covers the KILL errors: not an
|
||||
// operator, no such nick, and killing yourself.
|
||||
func TestIntegrationKillRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnvWithOper(t)
|
||||
|
||||
alice := env.dial(t)
|
||||
alice.register("alice")
|
||||
|
||||
bob := env.dial(t)
|
||||
bob.register("bob")
|
||||
|
||||
for _, step := range []struct {
|
||||
line, numeric string
|
||||
}{
|
||||
{"KILL bob :nope", " 481 "},
|
||||
{"OPER testoper testpass", " 381 "},
|
||||
{"KILL nobody123 :gone", " 401 "},
|
||||
{"KILL alice :me", " 483 "},
|
||||
} {
|
||||
alice.send(step.line)
|
||||
|
||||
reply := alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, step.numeric)
|
||||
})
|
||||
assertContains(t, reply, step.numeric, step.line)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationKill checks that the victim of a KILL is
|
||||
// told why, is disconnected, and is gone from its channels.
|
||||
func TestIntegrationKill(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnvWithOper(t)
|
||||
|
||||
alice := env.dial(t)
|
||||
alice.register("alice")
|
||||
|
||||
bob := env.dial(t)
|
||||
bob.register("bob")
|
||||
|
||||
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")
|
||||
})
|
||||
|
||||
alice.becomeOper()
|
||||
alice.send("KILL bob :bad behavior")
|
||||
|
||||
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",
|
||||
)
|
||||
}
|
||||
|
||||
// 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.becomeOper()
|
||||
|
||||
// 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",
|
||||
)
|
||||
}
|
||||
|
||||
// TestIntegrationUserMode covers MODE for a nick: changes
|
||||
// and queries of your own modes in any letter case, refusal
|
||||
// for another nick, and a rejected mode string changing
|
||||
// nothing.
|
||||
func TestIntegrationUserMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
alice := env.dial(t)
|
||||
alice.register("alice")
|
||||
|
||||
bob := env.dial(t)
|
||||
bob.register("bob")
|
||||
|
||||
const (
|
||||
notYours = " 502 alice :Can't change mode for other users"
|
||||
unknown = " 501 alice :Unknown MODE flag"
|
||||
)
|
||||
|
||||
for _, step := range []struct {
|
||||
line, want string
|
||||
}{
|
||||
{"MODE ALICE +w", " 221 alice +w"},
|
||||
{"MODE bob", notYours},
|
||||
{"MODE bob -w", notYours},
|
||||
{"MODE alice xw", unknown},
|
||||
{"MODE alice -w+z", unknown},
|
||||
{"MODE alice", " 221 alice +w"},
|
||||
{"MODE alice +w-w", " 221 alice +"},
|
||||
} {
|
||||
alice.send(step.line)
|
||||
|
||||
reply := alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, " 221 ") ||
|
||||
strings.Contains(l, " 501 ") ||
|
||||
strings.Contains(l, " 502 ")
|
||||
})
|
||||
|
||||
last := reply[len(reply)-1]
|
||||
if !strings.HasSuffix(last, step.want) {
|
||||
t.Errorf("%s: want %q, got %q", step.line, step.want, last)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationModeSecret tests +s (secret) channel
|
||||
// mode — verifies that +s can be set and the mode is
|
||||
// reflected in MODE queries.
|
||||
@@ -915,3 +1282,30 @@ func TestIntegrationThirdClientObserver(t *testing.T) {
|
||||
"carol receives trio message",
|
||||
)
|
||||
}
|
||||
|
||||
// TestIntegrationDefaultServerNameFallback checks that with
|
||||
// SERVER_NAME unset, as it is by default, VERSION, ADMIN and
|
||||
// TIME name the server "neoirc".
|
||||
func TestIntegrationDefaultServerNameFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnvWithConfig(t, &config.Config{})
|
||||
|
||||
alice := env.dial(t)
|
||||
alice.register("alice")
|
||||
|
||||
for _, step := range []struct {
|
||||
line, lastNumeric, want string
|
||||
}{
|
||||
{"VERSION", " 351 ", " 351 alice neoirc-test. neoirc "},
|
||||
{"ADMIN", " 259 ", " 256 alice neoirc "},
|
||||
{"TIME", " 391 ", " 391 alice neoirc "},
|
||||
} {
|
||||
alice.send(step.line)
|
||||
|
||||
reply := alice.readUntil(func(l string) bool {
|
||||
return strings.Contains(l, step.lastNumeric)
|
||||
})
|
||||
assertContains(t, reply, step.want, step.line)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,8 @@ func (c *Conn) deliverIRCMessage(
|
||||
c.deliverKickMsg(msg, text)
|
||||
case command == "INVITE":
|
||||
c.deliverInviteMsg(msg, text)
|
||||
case command == irc.CmdWallops:
|
||||
c.deliverWallops(msg, text)
|
||||
case command == irc.CmdMode:
|
||||
c.deliverMode(msg, text)
|
||||
case command == irc.CmdPing:
|
||||
@@ -337,6 +339,18 @@ func (c *Conn) deliverInviteMsg(
|
||||
c.sendFromServer("NOTICE", 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.
|
||||
func (c *Conn) deliverMode(
|
||||
msg *db.IRCMessage,
|
||||
|
||||
@@ -36,9 +36,39 @@ type testEnv struct {
|
||||
srv *ircserver.Server
|
||||
}
|
||||
|
||||
// testServerName is the SERVER_NAME of newTestEnv.
|
||||
const testServerName = "test.irc"
|
||||
|
||||
func newTestEnv(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
|
||||
return newTestEnvWithConfig(t, &config.Config{
|
||||
ServerName: testServerName,
|
||||
MOTD: "Welcome to test IRC",
|
||||
})
|
||||
}
|
||||
|
||||
// newTestEnvWithOper is newTestEnv with the operator name
|
||||
// testoper and password testpass.
|
||||
func newTestEnvWithOper(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
|
||||
return newTestEnvWithConfig(t, &config.Config{
|
||||
ServerName: testServerName,
|
||||
MOTD: "Welcome to test IRC",
|
||||
OperName: "testoper",
|
||||
OperPassword: "testpass",
|
||||
})
|
||||
}
|
||||
|
||||
// newTestEnvWithConfig starts an IRC server with cfg on a
|
||||
// fresh database.
|
||||
func newTestEnvWithConfig(
|
||||
t *testing.T,
|
||||
cfg *config.Config,
|
||||
) *testEnv {
|
||||
t.Helper()
|
||||
|
||||
dsn := fmt.Sprintf(
|
||||
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
|
||||
t.Name(),
|
||||
@@ -67,11 +97,6 @@ func newTestEnv(t *testing.T) *testEnv {
|
||||
|
||||
brk := broker.New()
|
||||
|
||||
cfg := &config.Config{ //nolint:exhaustruct
|
||||
ServerName: "test.irc",
|
||||
MOTD: "Welcome to test IRC",
|
||||
}
|
||||
|
||||
var listenConfig net.ListenConfig
|
||||
|
||||
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
|
||||
@@ -215,6 +240,33 @@ func (tc *testClient) register(nick string) []string {
|
||||
})
|
||||
}
|
||||
|
||||
// readUntilClosed returns the lines received until the
|
||||
// server closes the connection, and fails the test if it is
|
||||
// still open after testTimeout.
|
||||
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(
|
||||
"connection not closed: %v (lines: %v)",
|
||||
err, lines,
|
||||
)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// assertContains checks that at least one line matches the
|
||||
// given substring.
|
||||
func assertContains(
|
||||
@@ -233,6 +285,27 @@ func assertContains(
|
||||
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
|
||||
// RPL_ENDOFNAMES.
|
||||
func (tc *testClient) joinAndDrain(channel string) {
|
||||
|
||||
Reference in New Issue
Block a user