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 cancel context.CancelFunc } 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 terminates the connection on behalf of an // operator KILL issued from either transport. The victim // is told why, then its socket is closed so that the read // loop unblocks and serve() returns; without the close the // victim would keep a socket that looks alive but silently // delivers nothing. Disconnect is called from the killer's // goroutine, never the victim's. // // The notify-and-close half runs on its own goroutine and // under a short deadline. There is no per-client send // queue: send() writes straight to the victim's socket, so // a victim that has stopped reading would otherwise stall // the killer for the full writeTimeout on each of the two // writes -- wedging the operator's own serve() loop on the // IRC path, or the API request on the HTTP path. Any // client could trigger that deliberately. Disconnect // therefore returns as soon as the victim is marked closed; // the socket is closed shortly afterwards regardless of // whether the notification could be delivered. 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 = "*" } // Stop the relay goroutine, which would otherwise keep // polling a queue belonging to a deleted session. if c.cancel != nil { c.cancel() } go c.notifyKilledAndClose(nick, host, reason) } // notifyKilledAndClose delivers the KILL and ERROR lines to // a killed victim and then closes its socket. It runs on a // goroutine owned by neither the killer nor the victim, and // bounds both writes with killWriteWindow so an unresponsive // victim cannot hold the socket open indefinitely. func (c *Conn) notifyKilledAndClose( nick, host, reason string, ) { defer c.conn.Close() //nolint:errcheck,gosec 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. func (c *Conn) serve(ctx context.Context) { ctx, c.cancel = context.WithCancel(ctx) 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.Split(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, ) } }