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 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 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 } 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 } // 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, } } // 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.closed { return } } } func (c *Conn) cleanup(ctx context.Context) { c.mu.Lock() wasRegistered := c.registered 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.conn.Close() //nolint:errcheck,gosec } // send writes a formatted IRC line to the connection. func (c *Conn) send(line string) { _ = c.conn.SetWriteDeadline( time.Now().Add(writeTimeout), ) _, _ = 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.closed = true return } c.mu.Lock() c.sessionID = sessionID c.clientID = clientID c.registered = true c.mu.Unlock() // 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, ) } }