All checks were successful
check / check (push) Successful in 59s
1. ISUPPORT/applyChannelModes: extend IRC MODE handler to support +i/-i, +s/-s, +n/-n (routed through svc.SetChannelFlag), and +H/-H (hashcash bits with parameter parsing). Add 'n' (no external messages) as a proper DB-backed channel flag with is_no_external column (default: on). Update IRC ISUPPORT to CHANMODES=,,H,imnst to match actual support. 2. QueryChannelMode: rewrite to return complete mode string including all boolean flags (n, i, m, s, t) and parameterized modes (k, l, H), matching the HTTP handler's buildChannelModeString logic. Simplify buildChannelModeString to delegate to QueryChannelMode for consistency. 3. Service struct encapsulation: change exported fields (DB, Broker, Config, Log) to unexported (db, broker, config, log). Add NewTestService constructor for use by external test packages. Update ircserver export_test.go to use the new constructor. Closes #89
503 lines
10 KiB
Go
503 lines
10 KiB
Go
package ircserver
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"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/internal/service"
|
|
"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
|
|
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
|
|
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
|
|
}
|
|
|
|
// 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.
|
|
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.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.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,
|
|
)
|
|
}
|
|
}
|