check / check (push) Waiting to run
An IRC listener connection kept its own copy of its nick, so after the session's nick was changed from another client it went on sending under the old nick, which another user could then take. Now, whenever the relay passes on a NICK line and after the connection's own NICK is stored, the connection takes the nick its session holds in the database, read by session ID under the connection's lock. Taking the nick from the database rather than from the line means a connection can never follow another user's rename and send as them. Handlers read the nick under the same lock. Untested: an HTTP API rename landing while the connection's own NICK is being stored; that order cannot be produced reliably. Model: opus-5-5
700 lines
15 KiB
Go
700 lines
15 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
|
|
maxPasswordLen = 72 // bcrypt cannot hash a longer one
|
|
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
|
|
gotPass 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
|
|
}
|
|
|
|
// loadSessionNick sets c.nick to the nick the connection's
|
|
// session holds in the database, read by session ID. The
|
|
// connection's own NICK and the relay passing on a NICK line
|
|
// both call it, and it reads and sets the nick under c.mu, so
|
|
// whichever runs last leaves the connection on its session's
|
|
// nick. If the read fails, c.nick is left as it is.
|
|
func (c *Conn) loadSessionNick(ctx context.Context) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
nick, err := c.database.GetSessionNick(ctx, c.sessionID)
|
|
if err == nil {
|
|
c.nick = 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.currentNick()
|
|
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 {
|
|
nick := c.currentNick()
|
|
|
|
user := c.username
|
|
if user == "" {
|
|
user = nick
|
|
}
|
|
|
|
host := c.hostname
|
|
if host == "" {
|
|
host = c.remoteIP
|
|
}
|
|
|
|
return 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]
|
|
c.gotPass = true
|
|
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)
|
|
|
|
// Send welcome burst.
|
|
c.deliverWelcome()
|
|
c.deliverLusers(ctx)
|
|
c.deliverMOTD()
|
|
|
|
// If PASS was given before registration, store its
|
|
// password now, after the welcome burst, so that a
|
|
// NOTICE refusing it reaches a registered client. A
|
|
// failure to store it is only logged, as it is for PASS
|
|
// after registration.
|
|
if c.gotPass && c.checkPasswordLength(c.passWord) {
|
|
_ = c.setPassword(ctx, c.passWord)
|
|
}
|
|
|
|
// 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",
|
|
)
|
|
}
|
|
|
|
// checkPasswordLength reports whether pw is 8 to 72 bytes
|
|
// long, and sends the client a NOTICE saying so when it is
|
|
// not.
|
|
func (c *Conn) checkPasswordLength(pw string) bool {
|
|
if len(pw) >= minPasswordLen && len(pw) <= maxPasswordLen {
|
|
return true
|
|
}
|
|
|
|
c.sendFromServer("NOTICE", c.currentNick(),
|
|
"Password must be 8 to 72 bytes",
|
|
)
|
|
|
|
return false
|
|
}
|
|
|
|
// setPassword sets a bcrypt password on the session, and
|
|
// logs and returns the error when it was not stored.
|
|
func (c *Conn) setPassword(ctx context.Context, pw string) error {
|
|
// 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,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|