Files
neoirc/internal/ircserver/conn.go
T
sneak c28a8817a4
check / check (push) Waiting to run
Resolve IRC client hostnames off the accept loop (closes #156)
acceptLoop called newConn, which did the reverse DNS lookup, with its
three-second timeout, before the loop could accept the next
connection, so one client whose address had no answering reverse DNS
held up every other IRC connect. serve now does the lookup at its
start, on the connection's own goroutine, and the accept loop only
accepts.

The server keeps the lookup function in a field, as it does the ping
times, so that a test can give it one that blocks. The new test
checks that a second client registers while the first one's lookup
has not returned.

Model: opus-5-5
2026-10-21 01:02:40 +00:00

656 lines
14 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
writeTimeout = 30 * time.Second
killWriteWindow = 2 * time.Second
dnsTimeout = 3 * time.Second
pollInterval = 100 * time.Millisecond
pingInterval = 90 * time.Second
pongDeadline = 30 * time.Second
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, which come from
// serve(), from the relay goroutine and, for KILL, from
// Disconnect.
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(
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,
}
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 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 {
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)
},
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,
}
}
// 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.
// It first sets the client's hostname to what resolve returns
// for the client's address.
// Cancelling ctx when it returns stops the relay goroutine.
// When nothing has been read for interval the client is sent
// a PING, and when nothing is read within deadline after that
// the connection is closed.
func (c *Conn) serve(
ctx context.Context,
resolve func(ctx context.Context, addr string) string,
interval, deadline time.Duration,
) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// Stopping the server cancels ctx, and the cleanup must
// still end the session then.
defer c.cleanup(context.WithoutCancel(ctx))
// The hostname is looked up here, on this connection's
// goroutine, and not when the connection is accepted, so
// that a slow lookup holds up only this client.
c.hostname = resolve(ctx, c.remoteIP)
ping := time.AfterFunc(interval, func() {
c.send("PING :" + c.serverSfx)
})
defer ping.Stop()
scanner := bufio.NewScanner(c.conn)
scanner.Buffer(make([]byte, maxLineLen), maxLineLen)
for {
// Every line read starts both waits again.
ping.Reset(interval)
_ = c.conn.SetReadDeadline(
time.Now().Add(interval + deadline),
)
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)
// 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. CR, LF and NUL are removed first,
// as FormatMessage does for each parameter, so that a line
// built without it cannot end early either.
func (c *Conn) sendWithin(
timeout time.Duration,
line string,
) {
line = strings.ReplaceAll(line, "\r", "")
line = strings.ReplaceAll(line, "\n", "")
line = strings.ReplaceAll(line, "\x00", "")
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,
// PONG, QUIT, and CAP are accepted.
if !c.registered {
c.handlePreRegistration(ctx, msg)
return
}
// Every command is activity, so the idle cleanup must not
// remove this user.
err := c.database.UpdateLastSeen(
ctx, c.sessionID, c.clientID,
)
if err != nil {
c.log.Error(
"failed to update last_seen", "error", err,
)
}
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
}
if !service.ValidNick(msg.Params[0]) {
c.sendNumeric(
irc.ErrErroneusNickname,
msg.Params[0], "Erroneous nickname",
)
return
}
c.nick = msg.Params[0]
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.CmdPong:
// The answer to serve's PING, which unregistered
// clients are sent too.
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()
// 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 {
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,
)
}
}