Files
neoirc/internal/ircserver/conn.go
T
clawbot e28fa005ed
check / check (push) Waiting to run
Add the tier 3 utility IRC commands (closes #87)
USERHOST, VERSION, ADMIN, INFO, TIME, KILL and WALLOPS on both the
HTTP API and the IRC listener, and user mode +w.

Model: opus-5-5
2026-10-08 00:26:08 +00:00

617 lines
13 KiB
Go

package ircserver
import (
"bufio"
"context"
"fmt"
"log/slog"
"net"
"strconv"
"strings"
"sync"
"time"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc"
)
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
pongDeadline = 30 * time.Second
maxNickLen = 32
minPasswordLen = 8
maxHashcashBits = 40
)
// cmdHandler is the signature for registered IRC command
// handlers.
type cmdHandler func(ctx context.Context, msg *Message)
// Conn represents a single IRC client TCP connection.
type Conn struct {
conn net.Conn
log *slog.Logger
database *db.Database
brk *broker.Broker
cfg *config.Config
svc *service.Service
serverSfx string
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
nick string
username string
realname string
hostname string
remoteIP string
sessionID int64
clientID int64
registered bool
gotNick bool
gotUser bool
passWord string
lastQueueID int64
closed bool
killed bool
}
func newConn(
ctx context.Context,
tcpConn net.Conn,
log *slog.Logger,
database *db.Database,
brk *broker.Broker,
cfg *config.Config,
svc *service.Service,
) *Conn {
host, _, _ := net.SplitHostPort(tcpConn.RemoteAddr().String())
srvName := cfg.ServerName
if srvName == "" {
srvName = "neoirc"
}
conn := &Conn{ //nolint:exhaustruct // zero-value defaults
conn: tcpConn,
log: log,
database: database,
brk: brk,
cfg: cfg,
svc: svc,
serverSfx: srvName,
remoteIP: host,
hostname: resolveHost(ctx, host),
}
conn.commands = conn.buildCommandMap()
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 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
// to handler functions.
func (c *Conn) buildCommandMap() map[string]cmdHandler {
return map[string]cmdHandler{
irc.CmdPing: func(_ context.Context, msg *Message) {
c.handlePing(msg)
},
"PONG": func(context.Context, *Message) {},
irc.CmdNick: c.handleNick,
irc.CmdPrivmsg: c.handlePrivmsg,
irc.CmdNotice: c.handlePrivmsg,
irc.CmdJoin: c.handleJoin,
irc.CmdPart: c.handlePart,
irc.CmdQuit: func(_ context.Context, msg *Message) {
c.handleQuit(msg)
},
irc.CmdTopic: c.handleTopic,
irc.CmdMode: c.handleMode,
irc.CmdNames: c.handleNames,
irc.CmdList: func(ctx context.Context, _ *Message) { c.handleList(ctx) },
irc.CmdWhois: c.handleWhois,
irc.CmdWho: c.handleWho,
irc.CmdLusers: func(ctx context.Context, _ *Message) { c.handleLusers(ctx) },
irc.CmdMotd: func(context.Context, *Message) { c.deliverMOTD() },
irc.CmdOper: c.handleOper,
irc.CmdAway: c.handleAway,
irc.CmdKick: c.handleKick,
irc.CmdPass: c.handlePassPostReg,
"INVITE": c.handleInvite,
"CAP": func(_ context.Context, msg *Message) {
c.handleCAP(msg)
},
"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,
}
}
// resolveHost does a reverse DNS lookup, returning the IP
// on failure.
func resolveHost(ctx context.Context, addr string) string {
ctx, cancel := context.WithTimeout(ctx, dnsTimeout)
defer cancel()
resolver := &net.Resolver{} //nolint:exhaustruct
names, err := resolver.LookupAddr(ctx, addr)
if err != nil || len(names) == 0 {
return addr
}
return strings.TrimSuffix(names[0], ".")
}
// serve is the main loop for a single IRC client connection.
// Cancelling ctx when it returns stops the relay goroutine.
func (c *Conn) serve(ctx context.Context) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
defer c.cleanup(ctx)
scanner := bufio.NewScanner(c.conn)
scanner.Buffer(make([]byte, maxLineLen), maxLineLen)
for {
_ = c.conn.SetReadDeadline(
time.Now().Add(readTimeout),
)
if !scanner.Scan() {
return
}
line := scanner.Text()
if line == "" {
continue
}
msg := ParseMessage(line)
if msg == nil {
continue
}
c.handleMessage(ctx, msg)
if c.isClosed() {
return
}
}
}
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.UnregisterWireConn(sessID, c)
// 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
}
// 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.
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(
time.Now().Add(timeout),
)
_, _ = fmt.Fprintf(c.conn, "%s\r\n", line)
}
// sendNumeric sends a numeric reply from the server.
func (c *Conn) sendNumeric(
code irc.IRCMessageType,
params ...string,
) {
nick := c.nick
if nick == "" {
nick = "*"
}
allParams := make([]string, 0, 1+len(params))
allParams = append(allParams, nick)
allParams = append(allParams, params...)
c.send(FormatMessage(
c.serverSfx, code.Code(), allParams...,
))
}
// sendFromServer sends a message from the server.
func (c *Conn) sendFromServer(
command string, params ...string,
) {
c.send(FormatMessage(c.serverSfx, command, params...))
}
// hostmask returns the client's full hostmask
// (nick!user@host).
func (c *Conn) hostmask() string {
user := c.username
if user == "" {
user = c.nick
}
host := c.hostname
if host == "" {
host = c.remoteIP
}
return c.nick + "!" + user + "@" + host
}
// handleMessage dispatches a parsed IRC message using
// the command handler map.
func (c *Conn) handleMessage(
ctx context.Context,
msg *Message,
) {
// Before registration, only NICK, USER, PASS, PING,
// QUIT, and CAP are accepted.
if !c.registered {
c.handlePreRegistration(ctx, msg)
return
}
handler, ok := c.commands[msg.Command]
if !ok {
c.sendNumeric(
irc.ErrUnknownCommand,
msg.Command, "Unknown command",
)
return
}
handler(ctx, msg)
}
// handlePreRegistration handles messages before the
// connection is registered (NICK+USER received).
func (c *Conn) handlePreRegistration(
ctx context.Context,
msg *Message,
) {
switch msg.Command {
case irc.CmdPass:
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
"PASS", "Not enough parameters",
)
return
}
c.passWord = msg.Params[0]
case irc.CmdNick:
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNoNicknameGiven,
"No nickname given",
)
return
}
c.nick = msg.Params[0]
if len(c.nick) > maxNickLen {
c.nick = c.nick[:maxNickLen]
}
c.gotNick = true
case irc.CmdUser:
if len(msg.Params) < 4 { //nolint:mnd
c.sendNumeric(
irc.ErrNeedMoreParams,
"USER", "Not enough parameters",
)
return
}
c.username = msg.Params[0]
c.realname = msg.Params[3]
c.gotUser = true
case irc.CmdPing:
c.handlePing(msg)
return
case irc.CmdQuit:
c.handleQuit(msg)
return
case "CAP":
c.handleCAP(msg)
return
default:
c.sendNumeric(
irc.ErrNotRegistered,
"You have not registered",
)
return
}
// Try to complete registration once we have both
// NICK and USER.
if c.gotNick && c.gotUser {
c.completeRegistration(ctx)
}
}
// completeRegistration creates a session and sends the
// welcome burst.
func (c *Conn) completeRegistration(ctx context.Context) {
// Check if nick is valid.
if c.nick == "" {
c.sendNumeric(
irc.ErrNoNicknameGiven, "No nickname given",
)
return
}
// Create session in DB.
sessionID, clientID, _, err := c.database.CreateSession(
ctx, c.nick, c.username, c.hostname, c.remoteIP,
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint") ||
strings.Contains(err.Error(), "nick") {
c.sendNumeric(
irc.ErrNicknameInUse,
c.nick, "Nickname is already in use",
)
return
}
c.log.Error(
"failed to create session", "error", err,
)
c.send("ERROR :Internal server error")
c.mu.Lock()
c.closed = true
c.mu.Unlock()
return
}
c.mu.Lock()
c.sessionID = sessionID
c.clientID = clientID
c.registered = true
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
// session password.
if c.passWord != "" && len(c.passWord) >= minPasswordLen {
c.setPassword(ctx, c.passWord)
}
// Send welcome burst.
c.deliverWelcome()
c.deliverLusers(ctx)
c.deliverMOTD()
// Start the message relay goroutine.
go c.relayMessages(ctx)
}
// deliverWelcome sends 001-005 welcome numerics.
func (c *Conn) deliverWelcome() {
c.sendNumeric(irc.RplWelcome, fmt.Sprintf(
"Welcome to the %s Network, %s",
c.serverSfx, c.hostmask(),
))
c.sendNumeric(irc.RplYourHost, fmt.Sprintf(
"Your host is %s, running version neoirc",
c.serverSfx,
))
c.sendNumeric(
irc.RplCreated,
"This server was created recently",
)
c.sendNumeric(
irc.RplMyInfo,
c.serverSfx, "neoirc", "", "mnst",
)
c.sendNumeric(
irc.RplIsupport,
"CHANTYPES=#",
"NICKLEN=32",
"PREFIX=(ov)@+",
"CHANMODES=,,H,imnst",
"NETWORK="+c.serverSfx,
"are supported by this server",
)
}
// deliverLusers sends 251/252/254/255 server statistics.
func (c *Conn) deliverLusers(ctx context.Context) {
users, _ := c.database.GetUserCount(ctx)
opers, _ := c.database.GetOperCount(ctx)
channels, _ := c.database.GetChannelCount(ctx)
c.sendNumeric(irc.RplLuserClient, fmt.Sprintf(
"There are %d users and 0 invisible on 1 servers",
users,
))
c.sendNumeric(
irc.RplLuserOp,
strconv.FormatInt(opers, 10),
"operator(s) online",
)
c.sendNumeric(
irc.RplLuserChannels,
strconv.FormatInt(channels, 10),
"channels formed",
)
c.sendNumeric(irc.RplLuserMe, fmt.Sprintf(
"I have %d clients and 1 servers", users,
))
}
// deliverMOTD sends 375/372/376 MOTD lines.
func (c *Conn) deliverMOTD() {
motd := c.cfg.MOTD
if motd == "" {
c.sendNumeric(
irc.ErrNoMotd, "MOTD File is missing",
)
return
}
c.sendNumeric(irc.RplMotdStart, fmt.Sprintf(
"- %s Message of the Day -", c.serverSfx,
))
for line := range strings.SplitSeq(motd, "\n") {
c.sendNumeric(irc.RplMotd, "- "+line)
}
c.sendNumeric(
irc.RplEndOfMotd, "End of /MOTD command",
)
}
// setPassword sets a bcrypt password on the session.
func (c *Conn) setPassword(ctx context.Context, pw string) {
// Use the database's auth module to hash and store.
err := c.database.SetPassword(ctx, c.sessionID, pw)
if err != nil {
c.log.Error(
"failed to set password", "error", err,
)
}
}