Add the tier 3 utility IRC commands (closes #87)
check / check (push) Waiting to run

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:
2026-10-08 07:31:37 +02:00
committed by clawbot
parent b119b094f5
commit 0f2e63d0cc
19 changed files with 2552 additions and 129 deletions
+112 -6
View File
@@ -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 {