All checks were successful
check / check (push) Successful in 58s
Add a backward-compatible IRC protocol listener (RFC 1459/2812) that allows standard IRC clients (irssi, weechat, hexchat, etc.) to connect directly via TCP. Key features: - TCP listener on configurable port (IRC_LISTEN_ADDR env var, e.g. :6667) - Full IRC wire protocol parsing and formatting - Connection registration (NICK + USER + optional PASS) - Channel operations: JOIN, PART, MODE, TOPIC, NAMES, LIST, KICK, INVITE - Messaging: PRIVMSG, NOTICE (channel and direct) - Info commands: WHO, WHOIS, LUSERS, MOTD, AWAY - Operator support: OPER (with configured credentials) - PING/PONG keepalive - CAP negotiation (for modern client compatibility) - Full bridge to HTTP/JSON API (shared DB, broker, sessions) - Real-time message relay via broker notifications - Comprehensive test suite (parser + integration tests) The IRC listener is an optional component — disabled when IRC_LISTEN_ADDR is empty (the default). The Broker is now an Fx-provided dependency shared between HTTP handlers and the IRC server. closes #89
552 lines
11 KiB
Go
552 lines
11 KiB
Go
package ircserver
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/neoirc/internal/broker"
|
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
"git.eeqj.de/sneak/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
|
|
)
|
|
|
|
// 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
|
|
serverSfx string
|
|
|
|
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
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
func newConn(
|
|
ctx context.Context,
|
|
tcpConn net.Conn,
|
|
log *slog.Logger,
|
|
database *db.Database,
|
|
brk *broker.Broker,
|
|
cfg *config.Config,
|
|
) *Conn {
|
|
host, _, _ := net.SplitHostPort(tcpConn.RemoteAddr().String())
|
|
|
|
srvName := cfg.ServerName
|
|
if srvName == "" {
|
|
srvName = "neoirc"
|
|
}
|
|
|
|
return &Conn{ //nolint:exhaustruct // zero-value defaults
|
|
conn: tcpConn,
|
|
log: log,
|
|
database: database,
|
|
brk: brk,
|
|
cfg: cfg,
|
|
serverSfx: srvName,
|
|
remoteIP: host,
|
|
hostname: resolveHost(ctx, host),
|
|
}
|
|
}
|
|
|
|
// 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.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.broadcastQuit(ctx, nick, "Connection closed")
|
|
c.database.DeleteSession(ctx, sessID) //nolint:errcheck,gosec
|
|
}
|
|
|
|
c.conn.Close() //nolint:errcheck,gosec
|
|
}
|
|
|
|
func (c *Conn) broadcastQuit(
|
|
ctx context.Context,
|
|
nick, reason string,
|
|
) {
|
|
channels, err := c.database.GetSessionChannels(
|
|
ctx, c.sessionID,
|
|
)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
notified := make(map[int64]bool)
|
|
|
|
for _, ch := range channels {
|
|
chID, getErr := c.database.GetChannelByName(
|
|
ctx, ch.Name,
|
|
)
|
|
if getErr != nil {
|
|
continue
|
|
}
|
|
|
|
memberIDs, memErr := c.database.GetChannelMemberIDs(
|
|
ctx, chID,
|
|
)
|
|
if memErr != nil {
|
|
continue
|
|
}
|
|
|
|
for _, mid := range memberIDs {
|
|
if mid == c.sessionID || notified[mid] {
|
|
continue
|
|
}
|
|
|
|
notified[mid] = true
|
|
}
|
|
}
|
|
|
|
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
|
|
|
for sid := range notified {
|
|
dbID, _, insErr := c.database.InsertMessage(
|
|
ctx, irc.CmdQuit, nick, "", nil, body, nil,
|
|
)
|
|
if insErr != nil {
|
|
continue
|
|
}
|
|
|
|
_ = c.database.EnqueueToSession(ctx, sid, dbID)
|
|
c.brk.Notify(sid)
|
|
}
|
|
|
|
// Part from all channels so they get cleaned up.
|
|
for _, ch := range channels {
|
|
c.database.PartChannel(ctx, ch.ID, c.sessionID) //nolint:errcheck,gosec
|
|
c.database.DeleteChannelIfEmpty(ctx, ch.ID) //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.
|
|
//
|
|
//nolint:cyclop // dispatch table is inherently branchy
|
|
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
|
|
}
|
|
|
|
switch msg.Command {
|
|
case irc.CmdPing:
|
|
c.handlePing(msg)
|
|
case "PONG":
|
|
// Silently accept.
|
|
case irc.CmdNick:
|
|
c.handleNick(ctx, msg)
|
|
case irc.CmdPrivmsg, irc.CmdNotice:
|
|
c.handlePrivmsg(ctx, msg)
|
|
case irc.CmdJoin:
|
|
c.handleJoin(ctx, msg)
|
|
case irc.CmdPart:
|
|
c.handlePart(ctx, msg)
|
|
case irc.CmdQuit:
|
|
c.handleQuit(msg)
|
|
case irc.CmdTopic:
|
|
c.handleTopic(ctx, msg)
|
|
case irc.CmdMode:
|
|
c.handleMode(ctx, msg)
|
|
case irc.CmdNames:
|
|
c.handleNames(ctx, msg)
|
|
case irc.CmdList:
|
|
c.handleList(ctx)
|
|
case irc.CmdWhois:
|
|
c.handleWhois(ctx, msg)
|
|
case irc.CmdWho:
|
|
c.handleWho(ctx, msg)
|
|
case irc.CmdLusers:
|
|
c.handleLusers(ctx)
|
|
case irc.CmdMotd:
|
|
c.deliverMOTD()
|
|
case irc.CmdOper:
|
|
c.handleOper(ctx, msg)
|
|
case irc.CmdAway:
|
|
c.handleAway(ctx, msg)
|
|
case irc.CmdKick:
|
|
c.handleKick(ctx, msg)
|
|
case irc.CmdPass:
|
|
c.handlePassPostReg(ctx, msg)
|
|
case "INVITE":
|
|
c.handleInvite(ctx, msg)
|
|
case "CAP":
|
|
c.handleCAP(msg)
|
|
case "USERHOST":
|
|
c.handleUserhost(ctx, msg)
|
|
default:
|
|
c.sendNumeric(
|
|
irc.ErrUnknownCommand,
|
|
msg.Command, "Unknown command",
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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,mnst",
|
|
"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,
|
|
)
|
|
}
|
|
}
|