All checks were successful
check / check (push) Successful in 1m12s
Addresses the 2026-08-10 FAIL review (findings 1-7, 9, 10). KILL never terminated the victim's connection on either transport: both paths called BroadcastQuit, which deletes the session row and tells the victim's peers it quit, but leaves the victim holding a socket that looks alive and silently delivers nothing while its nick is freed for reuse. Service now owns a session-ID keyed registry of live wire connections that ircserver populates at registration, and both KILL paths go through the new Service.KillSession, which broadcasts the QUIT and then sends the victim a KILL and ERROR :Closing Link before closing its socket. The victim's relay goroutine is cancelled and its cleanup no longer re-broadcasts a QUIT for an already-deleted session. TestIntegrationKill now asserts the victim reads to EOF and is gone from NAMES and WHO, not just that an observer saw the QUIT relay. HTTP MODE <othernick> with no body answered with the requester's own modes, because the target check sat inside the mode-change branch. The check is hoisted above the query/change split, and both transports now compare nicks with EqualFold since IRC nicks are case-insensitive. Service.QueryUserMode returned "+" for a database failure, making an unreadable mode indistinguishable from an unset one; it now returns an error, and both callers surface it. db.GetUserhostInfo likewise treated every scan error as "nick not found"; only sql.ErrNoRows is skipped now. The four new unsynchronized c.nick reads this branch introduced are read through currentNick() under c.mu, and c.closed is now guarded everywhere because KILL writes it from another client's goroutine. Conn.send takes a write mutex, as a connection is now written to by three goroutines. server.Server.Run was left with no in-tree callers when its body was inlined into the fx OnStart hook; it is deleted rather than left to drift. INFO and VERSION had two implementations that had already diverged: the version string is now Service.ServerVersion and the INFO body is Service.InfoLines, used verbatim by both transports. The ctx parameters on handleVersion/handleAdmin/handleInfo/handleTime existed only to be discarded and are gone.
1525 lines
26 KiB
Go
1525 lines
26 KiB
Go
package ircserver
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"sneak.berlin/go/neoirc/internal/service"
|
|
"sneak.berlin/go/neoirc/pkg/irc"
|
|
)
|
|
|
|
// sendIRCError maps a service.IRCError to an IRC numeric
|
|
// reply on the wire.
|
|
func (c *Conn) sendIRCError(err error) {
|
|
var ircErr *service.IRCError
|
|
if errors.As(err, &ircErr) {
|
|
args := make([]string, 0, len(ircErr.Params)+1)
|
|
args = append(args, ircErr.Params...)
|
|
args = append(args, ircErr.Message)
|
|
c.sendNumeric(ircErr.Code, args...)
|
|
}
|
|
}
|
|
|
|
// handleCAP silently acknowledges CAP negotiation.
|
|
func (c *Conn) handleCAP(msg *Message) {
|
|
if len(msg.Params) == 0 {
|
|
return
|
|
}
|
|
|
|
sub := strings.ToUpper(msg.Params[0])
|
|
if sub == "LS" {
|
|
c.send(FormatMessage(
|
|
c.serverSfx, "CAP", "*", "LS", "",
|
|
))
|
|
}
|
|
|
|
// CAP END and other subcommands are silently ignored.
|
|
}
|
|
|
|
// handlePing replies with a PONG.
|
|
func (c *Conn) handlePing(msg *Message) {
|
|
token := c.serverSfx
|
|
|
|
if len(msg.Params) > 0 {
|
|
token = msg.Params[0]
|
|
}
|
|
|
|
c.sendFromServer("PONG", c.serverSfx, token)
|
|
}
|
|
|
|
// handleNick changes the user's nickname via the shared
|
|
// service layer.
|
|
func (c *Conn) handleNick(
|
|
ctx context.Context,
|
|
msg *Message,
|
|
) {
|
|
if len(msg.Params) < 1 {
|
|
c.sendNumeric(
|
|
irc.ErrNoNicknameGiven, "No nickname given",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
newNick := msg.Params[0]
|
|
if len(newNick) > maxNickLen {
|
|
newNick = newNick[:maxNickLen]
|
|
}
|
|
|
|
oldMask := c.hostmask()
|
|
oldNick := c.nick
|
|
|
|
err := c.svc.ChangeNick(
|
|
ctx, c.sessionID, oldNick, newNick,
|
|
)
|
|
if err != nil {
|
|
c.sendIRCError(err)
|
|
|
|
return
|
|
}
|
|
|
|
c.mu.Lock()
|
|
c.nick = newNick
|
|
c.mu.Unlock()
|
|
|
|
// Echo NICK change to the client on wire.
|
|
c.send(FormatMessage(oldMask, "NICK", newNick))
|
|
}
|
|
|
|
// handlePrivmsg handles PRIVMSG and NOTICE commands via
|
|
// the shared service layer.
|
|
func (c *Conn) handlePrivmsg(
|
|
ctx context.Context,
|
|
msg *Message,
|
|
) {
|
|
if len(msg.Params) < 1 {
|
|
c.sendNumeric(
|
|
irc.ErrNoRecipient,
|
|
"No recipient given ("+msg.Command+")",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if len(msg.Params) < 2 { //nolint:mnd
|
|
c.sendNumeric(
|
|
irc.ErrNoTextToSend, "No text to send",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
target := msg.Params[0]
|
|
text := msg.Params[1]
|
|
body, _ := json.Marshal([]string{text}) //nolint:errchkjson
|
|
|
|
if strings.HasPrefix(target, "#") {
|
|
_, _, err := c.svc.SendChannelMessage(
|
|
ctx, c.sessionID, c.nick,
|
|
msg.Command, target, body, nil,
|
|
)
|
|
if err != nil {
|
|
c.sendIRCError(err)
|
|
}
|
|
} else {
|
|
result, err := c.svc.SendDirectMessage(
|
|
ctx, c.sessionID, c.nick,
|
|
msg.Command, target, body, nil,
|
|
)
|
|
if err != nil {
|
|
c.sendIRCError(err)
|
|
|
|
return
|
|
}
|
|
|
|
if result.AwayMsg != "" {
|
|
c.sendNumeric(
|
|
irc.RplAway, target, result.AwayMsg,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleJoin joins one or more channels via the shared
|
|
// service layer.
|
|
func (c *Conn) handleJoin(
|
|
ctx context.Context,
|
|
msg *Message,
|
|
) {
|
|
if len(msg.Params) < 1 {
|
|
c.sendNumeric(
|
|
irc.ErrNeedMoreParams,
|
|
"JOIN", "Not enough parameters",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
channels := strings.Split(msg.Params[0], ",")
|
|
|
|
for _, chanName := range channels {
|
|
chanName = strings.TrimSpace(chanName)
|
|
|
|
if !strings.HasPrefix(chanName, "#") {
|
|
chanName = "#" + chanName
|
|
}
|
|
|
|
c.joinChannel(ctx, chanName)
|
|
}
|
|
}
|
|
|
|
// joinChannel joins a single channel using the service
|
|
// and delivers topic/names on the wire.
|
|
func (c *Conn) joinChannel(
|
|
ctx context.Context,
|
|
channel string,
|
|
) {
|
|
result, err := c.svc.JoinChannel(
|
|
ctx, c.sessionID, c.nick, channel, "",
|
|
)
|
|
if err != nil {
|
|
c.sendIRCError(err)
|
|
|
|
if !errors.As(err, new(*service.IRCError)) {
|
|
c.log.Error(
|
|
"join channel failed", "error", err,
|
|
)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// Send JOIN echo to this client directly on wire.
|
|
c.send(FormatMessage(c.hostmask(), "JOIN", channel))
|
|
|
|
// Send topic.
|
|
c.deliverTopic(ctx, channel, result.ChannelID)
|
|
|
|
// Send NAMES.
|
|
c.deliverNames(ctx, channel, result.ChannelID)
|
|
}
|
|
|
|
// deliverTopic sends RPL_TOPIC or RPL_NOTOPIC.
|
|
func (c *Conn) deliverTopic(
|
|
ctx context.Context,
|
|
channel string,
|
|
chID int64,
|
|
) {
|
|
channels, err := c.database.ListChannels(
|
|
ctx, c.sessionID,
|
|
)
|
|
|
|
topic := ""
|
|
|
|
if err == nil {
|
|
for _, ch := range channels {
|
|
if ch.Name == channel {
|
|
topic = ch.Topic
|
|
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if topic == "" {
|
|
c.sendNumeric(
|
|
irc.RplNoTopic, channel, "No topic is set",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
c.sendNumeric(irc.RplTopic, channel, topic)
|
|
|
|
meta, tmErr := c.database.GetTopicMeta(ctx, chID)
|
|
if tmErr == nil && meta != nil {
|
|
c.sendNumeric(
|
|
irc.RplTopicWhoTime, channel,
|
|
meta.SetBy,
|
|
strconv.FormatInt(meta.SetAt.Unix(), 10),
|
|
)
|
|
}
|
|
}
|
|
|
|
// deliverNames sends RPL_NAMREPLY and RPL_ENDOFNAMES.
|
|
func (c *Conn) deliverNames(
|
|
ctx context.Context,
|
|
channel string,
|
|
chID int64,
|
|
) {
|
|
members, err := c.database.ChannelMembers(ctx, chID)
|
|
if err != nil {
|
|
c.sendNumeric(
|
|
irc.RplEndOfNames,
|
|
channel, "End of /NAMES list",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
names := make([]string, 0, len(members))
|
|
|
|
for _, member := range members {
|
|
prefix := ""
|
|
if member.IsOperator {
|
|
prefix = "@"
|
|
} else if member.IsVoiced {
|
|
prefix = "+"
|
|
}
|
|
|
|
names = append(names, prefix+member.Nick)
|
|
}
|
|
|
|
nameStr := strings.Join(names, " ")
|
|
|
|
c.sendNumeric(
|
|
irc.RplNamReply, "=", channel, nameStr,
|
|
)
|
|
c.sendNumeric(
|
|
irc.RplEndOfNames,
|
|
channel, "End of /NAMES list",
|
|
)
|
|
}
|
|
|
|
// handlePart leaves one or more channels via the shared
|
|
// service layer.
|
|
func (c *Conn) handlePart(
|
|
ctx context.Context,
|
|
msg *Message,
|
|
) {
|
|
if len(msg.Params) < 1 {
|
|
c.sendNumeric(
|
|
irc.ErrNeedMoreParams,
|
|
"PART", "Not enough parameters",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
reason := ""
|
|
if len(msg.Params) > 1 {
|
|
reason = msg.Params[1]
|
|
}
|
|
|
|
channels := strings.Split(msg.Params[0], ",")
|
|
|
|
for _, ch := range channels {
|
|
ch = strings.TrimSpace(ch)
|
|
c.partChannel(ctx, ch, reason)
|
|
}
|
|
}
|
|
|
|
// partChannel leaves a single channel using the service.
|
|
func (c *Conn) partChannel(
|
|
ctx context.Context,
|
|
channel, reason string,
|
|
) {
|
|
err := c.svc.PartChannel(
|
|
ctx, c.sessionID, c.nick, channel, reason,
|
|
)
|
|
if err != nil {
|
|
c.sendIRCError(err)
|
|
|
|
return
|
|
}
|
|
|
|
// Echo PART to the client on wire.
|
|
if reason != "" {
|
|
c.send(FormatMessage(
|
|
c.hostmask(), "PART", channel, reason,
|
|
))
|
|
} else {
|
|
c.send(FormatMessage(
|
|
c.hostmask(), "PART", channel,
|
|
))
|
|
}
|
|
}
|
|
|
|
// handleQuit handles the QUIT command.
|
|
func (c *Conn) handleQuit(msg *Message) {
|
|
reason := "Client quit"
|
|
|
|
if len(msg.Params) > 0 {
|
|
reason = msg.Params[0]
|
|
}
|
|
|
|
c.send("ERROR :Closing Link: " + c.hostname +
|
|
" (Quit: " + reason + ")")
|
|
|
|
c.mu.Lock()
|
|
c.closed = true
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// handleTopic gets or sets a channel topic via the shared
|
|
// service layer.
|
|
func (c *Conn) handleTopic(
|
|
ctx context.Context,
|
|
msg *Message,
|
|
) {
|
|
if len(msg.Params) < 1 {
|
|
c.sendNumeric(
|
|
irc.ErrNeedMoreParams,
|
|
"TOPIC", "Not enough parameters",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
channel := msg.Params[0]
|
|
|
|
// If no second param, query the topic.
|
|
if len(msg.Params) < 2 { //nolint:mnd
|
|
c.queryTopic(ctx, channel)
|
|
|
|
return
|
|
}
|
|
|
|
// Set topic via service.
|
|
newTopic := msg.Params[1]
|
|
|
|
err := c.svc.SetTopic(
|
|
ctx, c.sessionID, c.nick, channel, newTopic,
|
|
)
|
|
if err != nil {
|
|
c.sendIRCError(err)
|
|
|
|
return
|
|
}
|
|
|
|
// Echo TOPIC to the setting client on wire.
|
|
c.send(FormatMessage(
|
|
c.hostmask(), "TOPIC", channel, newTopic,
|
|
))
|
|
}
|
|
|
|
// queryTopic sends the current topic for a channel.
|
|
func (c *Conn) queryTopic(
|
|
ctx context.Context,
|
|
channel string,
|
|
) {
|
|
chID, err := c.database.GetChannelByName(ctx, channel)
|
|
if err != nil {
|
|
c.sendNumeric(
|
|
irc.ErrNoSuchChannel,
|
|
channel, "No such channel",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
c.deliverTopic(ctx, channel, chID)
|
|
}
|
|
|
|
// handleMode handles MODE queries and changes.
|
|
func (c *Conn) handleMode(
|
|
ctx context.Context,
|
|
msg *Message,
|
|
) {
|
|
if len(msg.Params) < 1 {
|
|
c.sendNumeric(
|
|
irc.ErrNeedMoreParams,
|
|
"MODE", "Not enough parameters",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
target := msg.Params[0]
|
|
|
|
if strings.HasPrefix(target, "#") {
|
|
c.handleChannelMode(ctx, msg)
|
|
} else {
|
|
c.handleUserMode(ctx, msg)
|
|
}
|
|
}
|
|
|
|
// handleChannelMode handles MODE for channels.
|
|
func (c *Conn) handleChannelMode(
|
|
ctx context.Context,
|
|
msg *Message,
|
|
) {
|
|
channel := msg.Params[0]
|
|
|
|
chID, err := c.database.GetChannelByName(ctx, channel)
|
|
if err != nil {
|
|
c.sendNumeric(
|
|
irc.ErrNoSuchChannel,
|
|
channel, "No such channel",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// Query mode if no mode string given.
|
|
if len(msg.Params) < 2 { //nolint:mnd
|
|
modeStr := c.svc.QueryChannelMode(ctx, chID)
|
|
c.sendNumeric(
|
|
irc.RplChannelModeIs, channel, modeStr,
|
|
)
|
|
|
|
created, _ := c.database.GetChannelCreatedAt(
|
|
ctx, chID,
|
|
)
|
|
if !created.IsZero() {
|
|
c.sendNumeric(
|
|
irc.RplCreationTime, channel,
|
|
strconv.FormatInt(created.Unix(), 10),
|
|
)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// Need ops to change modes — validated by service.
|
|
_, opErr := c.svc.ValidateChannelOp(
|
|
ctx, c.sessionID, channel,
|
|
)
|
|
if opErr != nil {
|
|
c.sendIRCError(opErr)
|
|
|
|
return
|
|
}
|
|
|
|
modeStr := msg.Params[1]
|
|
modeArgs := msg.Params[2:]
|
|
|
|
c.applyChannelModes(
|
|
ctx, channel, chID, modeStr, modeArgs,
|
|
)
|
|
}
|
|
|
|
// modeResult holds the delta strings produced by a
|
|
// single mode-char application.
|
|
type modeResult struct {
|
|
applied string
|
|
appliedArgs string
|
|
consumed int
|
|
skip bool
|
|
}
|
|
|
|
// applyHashcashMode handles +H/-H (hashcash difficulty).
|
|
func (c *Conn) applyHashcashMode(
|
|
ctx context.Context,
|
|
chID int64,
|
|
adding bool,
|
|
args []string,
|
|
argIdx int,
|
|
) modeResult {
|
|
if !adding {
|
|
_ = c.database.SetChannelHashcashBits(
|
|
ctx, chID, 0,
|
|
)
|
|
|
|
return modeResult{
|
|
applied: "-H",
|
|
appliedArgs: "",
|
|
consumed: 0,
|
|
skip: false,
|
|
}
|
|
}
|
|
|
|
if argIdx >= len(args) {
|
|
return modeResult{
|
|
applied: "",
|
|
appliedArgs: "",
|
|
consumed: 0,
|
|
skip: true,
|
|
}
|
|
}
|
|
|
|
bitsStr := args[argIdx]
|
|
|
|
bits, parseErr := strconv.Atoi(bitsStr)
|
|
if parseErr != nil ||
|
|
bits < 1 || bits > maxHashcashBits {
|
|
c.sendNumeric(
|
|
irc.ErrUnknownMode, "H",
|
|
"is unknown mode char to me",
|
|
)
|
|
|
|
return modeResult{
|
|
applied: "",
|
|
appliedArgs: "",
|
|
consumed: 1,
|
|
skip: true,
|
|
}
|
|
}
|
|
|
|
_ = c.database.SetChannelHashcashBits(
|
|
ctx, chID, bits,
|
|
)
|
|
|
|
return modeResult{
|
|
applied: "+H",
|
|
appliedArgs: " " + bitsStr,
|
|
consumed: 1,
|
|
skip: false,
|
|
}
|
|
}
|
|
|
|
// applyMemberMode handles +o/-o and +v/-v.
|
|
func (c *Conn) applyMemberMode(
|
|
ctx context.Context,
|
|
chID int64,
|
|
channel string,
|
|
modeChar rune,
|
|
adding bool,
|
|
args []string,
|
|
argIdx int,
|
|
) modeResult {
|
|
if argIdx >= len(args) {
|
|
return modeResult{
|
|
applied: "",
|
|
appliedArgs: "",
|
|
consumed: 0,
|
|
skip: true,
|
|
}
|
|
}
|
|
|
|
targetNick := args[argIdx]
|
|
|
|
err := c.svc.ApplyMemberMode(
|
|
ctx, chID, channel,
|
|
targetNick, modeChar, adding,
|
|
)
|
|
if err != nil {
|
|
c.sendIRCError(err)
|
|
|
|
return modeResult{
|
|
applied: "",
|
|
appliedArgs: "",
|
|
consumed: 1,
|
|
skip: true,
|
|
}
|
|
}
|
|
|
|
prefix := "+"
|
|
if !adding {
|
|
prefix = "-"
|
|
}
|
|
|
|
return modeResult{
|
|
applied: prefix + string(modeChar),
|
|
appliedArgs: " " + targetNick,
|
|
consumed: 1,
|
|
skip: false,
|
|
}
|
|
}
|
|
|
|
// applyChannelModes applies mode changes using the
|
|
// service for individual mode operations.
|
|
func (c *Conn) applyChannelModes(
|
|
ctx context.Context,
|
|
channel string,
|
|
chID int64,
|
|
modeStr string,
|
|
args []string,
|
|
) {
|
|
adding := true
|
|
argIdx := 0
|
|
applied := ""
|
|
appliedArgs := ""
|
|
|
|
for _, modeChar := range modeStr {
|
|
var res modeResult
|
|
|
|
switch modeChar {
|
|
case '+':
|
|
adding = true
|
|
|
|
continue
|
|
case '-':
|
|
adding = false
|
|
|
|
continue
|
|
case 'i', 'm', 'n', 's', 't':
|
|
_ = c.svc.SetChannelFlag(
|
|
ctx, chID, modeChar, adding,
|
|
)
|
|
|
|
prefix := "+"
|
|
if !adding {
|
|
prefix = "-"
|
|
}
|
|
|
|
res = modeResult{
|
|
applied: prefix + string(modeChar),
|
|
appliedArgs: "",
|
|
consumed: 0,
|
|
skip: false,
|
|
}
|
|
case 'H':
|
|
res = c.applyHashcashMode(
|
|
ctx, chID, adding, args, argIdx,
|
|
)
|
|
case 'o', 'v':
|
|
res = c.applyMemberMode(
|
|
ctx, chID, channel,
|
|
modeChar, adding, args, argIdx,
|
|
)
|
|
default:
|
|
c.sendNumeric(
|
|
irc.ErrUnknownMode,
|
|
string(modeChar),
|
|
"is unknown mode char to me",
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
argIdx += res.consumed
|
|
|
|
if !res.skip {
|
|
applied += res.applied
|
|
appliedArgs += res.appliedArgs
|
|
}
|
|
}
|
|
|
|
if applied != "" {
|
|
modeReply := applied
|
|
if appliedArgs != "" {
|
|
modeReply += appliedArgs
|
|
}
|
|
|
|
c.send(FormatMessage(
|
|
c.hostmask(), "MODE", channel, modeReply,
|
|
))
|
|
|
|
c.svc.BroadcastMode(
|
|
ctx, c.nick, channel, chID, modeReply,
|
|
)
|
|
}
|
|
}
|
|
|
|
// handleUserMode handles MODE for users.
|
|
func (c *Conn) handleUserMode(
|
|
ctx context.Context,
|
|
msg *Message,
|
|
) {
|
|
target := msg.Params[0]
|
|
|
|
if !strings.EqualFold(target, c.currentNick()) {
|
|
c.sendNumeric(
|
|
irc.ErrUsersDoNotMatch,
|
|
"Can't change mode for other users",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// Mode query (no mode string).
|
|
if len(msg.Params) < 2 { //nolint:mnd
|
|
modes, err := c.svc.QueryUserMode(
|
|
ctx, c.sessionID,
|
|
)
|
|
if err != nil {
|
|
c.log.Error(
|
|
"query user mode failed", "error", err,
|
|
)
|
|
c.sendNumeric(
|
|
irc.ErrUmodeUnknownFlag,
|
|
"Unable to read user modes",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
c.sendNumeric(irc.RplUmodeIs, modes)
|
|
|
|
return
|
|
}
|
|
|
|
newModes, err := c.svc.ApplyUserMode(
|
|
ctx, c.sessionID, msg.Params[1],
|
|
)
|
|
if err != nil {
|
|
var ircErr *service.IRCError
|
|
if errors.As(err, &ircErr) {
|
|
c.sendNumeric(ircErr.Code, ircErr.Message)
|
|
|
|
return
|
|
}
|
|
|
|
c.sendNumeric(
|
|
irc.ErrUmodeUnknownFlag,
|
|
"Unknown MODE flag",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
c.sendNumeric(irc.RplUmodeIs, newModes)
|
|
}
|
|
|
|
// handleNames replies with channel member list.
|
|
func (c *Conn) handleNames(
|
|
ctx context.Context,
|
|
msg *Message,
|
|
) {
|
|
if len(msg.Params) < 1 {
|
|
c.sendNumeric(
|
|
irc.RplEndOfNames,
|
|
"*", "End of /NAMES list",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
channel := msg.Params[0]
|
|
|
|
chID, err := c.database.GetChannelByName(ctx, channel)
|
|
if err != nil {
|
|
c.sendNumeric(
|
|
irc.RplEndOfNames,
|
|
channel, "End of /NAMES list",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
c.deliverNames(ctx, channel, chID)
|
|
}
|
|
|
|
// handleList sends the channel list.
|
|
func (c *Conn) handleList(ctx context.Context) {
|
|
channels, err := c.database.ListAllChannelsWithCounts(
|
|
ctx,
|
|
)
|
|
if err != nil {
|
|
c.sendNumeric(
|
|
irc.RplListEnd, "End of /LIST",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
c.sendNumeric(irc.RplListStart, "Channel", "Users Name")
|
|
|
|
for idx := range channels {
|
|
c.sendNumeric(
|
|
irc.RplList, channels[idx].Name,
|
|
strconv.FormatInt(
|
|
channels[idx].MemberCount, 10,
|
|
),
|
|
channels[idx].Topic,
|
|
)
|
|
}
|
|
|
|
c.sendNumeric(irc.RplListEnd, "End of /LIST")
|
|
}
|
|
|
|
// handleWhois replies with user info. Individual numeric
|
|
// replies are split into focused helper methods.
|
|
func (c *Conn) handleWhois(
|
|
ctx context.Context,
|
|
msg *Message,
|
|
) {
|
|
if len(msg.Params) < 1 {
|
|
c.sendNumeric(
|
|
irc.ErrNoNicknameGiven, "No nickname given",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
target := msg.Params[0]
|
|
|
|
if len(msg.Params) > 1 {
|
|
target = msg.Params[1]
|
|
}
|
|
|
|
targetID, err := c.database.GetSessionByNick(
|
|
ctx, target,
|
|
)
|
|
if err != nil {
|
|
c.sendNumeric(
|
|
irc.ErrNoSuchNick, target, "No such nick",
|
|
)
|
|
c.sendNumeric(
|
|
irc.RplEndOfWhois,
|
|
target, "End of /WHOIS list",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
c.whoisUser(ctx, target, targetID)
|
|
c.whoisServer(target)
|
|
c.whoisOper(ctx, target, targetID)
|
|
c.whoisChannels(ctx, target, targetID)
|
|
c.whoisIdle(ctx, target, targetID)
|
|
c.whoisAway(ctx, target, targetID)
|
|
|
|
c.sendNumeric(
|
|
irc.RplEndOfWhois,
|
|
target, "End of /WHOIS list",
|
|
)
|
|
}
|
|
|
|
// whoisUser sends 311 RPL_WHOISUSER.
|
|
func (c *Conn) whoisUser(
|
|
ctx context.Context,
|
|
target string,
|
|
targetID int64,
|
|
) {
|
|
hostInfo, _ := c.database.GetSessionHostInfo(
|
|
ctx, targetID,
|
|
)
|
|
|
|
username := target
|
|
hostname := "*"
|
|
|
|
if hostInfo != nil {
|
|
username = hostInfo.Username
|
|
hostname = hostInfo.Hostname
|
|
}
|
|
|
|
c.sendNumeric(
|
|
irc.RplWhoisUser, target,
|
|
username, hostname, "*", target,
|
|
)
|
|
}
|
|
|
|
// whoisServer sends 312 RPL_WHOISSERVER.
|
|
func (c *Conn) whoisServer(target string) {
|
|
c.sendNumeric(
|
|
irc.RplWhoisServer, target,
|
|
c.serverSfx, "neoirc server",
|
|
)
|
|
}
|
|
|
|
// whoisOper sends 313 RPL_WHOISOPERATOR if applicable.
|
|
func (c *Conn) whoisOper(
|
|
ctx context.Context,
|
|
target string,
|
|
targetID int64,
|
|
) {
|
|
isOper, _ := c.database.IsSessionOper(ctx, targetID)
|
|
if isOper {
|
|
c.sendNumeric(
|
|
irc.RplWhoisOperator,
|
|
target, "is an IRC operator",
|
|
)
|
|
}
|
|
}
|
|
|
|
// whoisChannels sends 319 RPL_WHOISCHANNELS.
|
|
func (c *Conn) whoisChannels(
|
|
ctx context.Context,
|
|
target string,
|
|
targetID int64,
|
|
) {
|
|
userChannels, _ := c.database.GetSessionChannels(
|
|
ctx, targetID,
|
|
)
|
|
if len(userChannels) == 0 {
|
|
return
|
|
}
|
|
|
|
chanList := make([]string, 0, len(userChannels))
|
|
|
|
for _, userChan := range userChannels {
|
|
chID, getErr := c.database.GetChannelByName(
|
|
ctx, userChan.Name,
|
|
)
|
|
if getErr != nil {
|
|
chanList = append(chanList, userChan.Name)
|
|
|
|
continue
|
|
}
|
|
|
|
isChOp, _ := c.database.IsChannelOperator(
|
|
ctx, chID, targetID,
|
|
)
|
|
isVoiced, _ := c.database.IsChannelVoiced(
|
|
ctx, chID, targetID,
|
|
)
|
|
|
|
prefix := ""
|
|
if isChOp {
|
|
prefix = "@"
|
|
} else if isVoiced {
|
|
prefix = "+"
|
|
}
|
|
|
|
chanList = append(chanList, prefix+userChan.Name)
|
|
}
|
|
|
|
c.sendNumeric(
|
|
irc.RplWhoisChannels, target,
|
|
strings.Join(chanList, " "),
|
|
)
|
|
}
|
|
|
|
// whoisIdle sends 317 RPL_WHOISIDLE.
|
|
func (c *Conn) whoisIdle(
|
|
ctx context.Context,
|
|
target string,
|
|
targetID int64,
|
|
) {
|
|
lastSeen, _ := c.database.GetSessionLastSeen(
|
|
ctx, targetID,
|
|
)
|
|
created, _ := c.database.GetSessionCreatedAt(
|
|
ctx, targetID,
|
|
)
|
|
|
|
if lastSeen.IsZero() {
|
|
return
|
|
}
|
|
|
|
idle := int64(time.Since(lastSeen).Seconds())
|
|
|
|
signonTS := int64(0)
|
|
if !created.IsZero() {
|
|
signonTS = created.Unix()
|
|
}
|
|
|
|
c.sendNumeric(
|
|
irc.RplWhoisIdle, target,
|
|
strconv.FormatInt(idle, 10),
|
|
strconv.FormatInt(signonTS, 10),
|
|
"seconds idle, signon time",
|
|
)
|
|
}
|
|
|
|
// whoisAway sends 301 RPL_AWAY if the target is away.
|
|
func (c *Conn) whoisAway(
|
|
ctx context.Context,
|
|
target string,
|
|
targetID int64,
|
|
) {
|
|
away, _ := c.database.GetAway(ctx, targetID)
|
|
if away != "" {
|
|
c.sendNumeric(irc.RplAway, target, away)
|
|
}
|
|
}
|
|
|
|
// handleWho sends WHO replies for a channel.
|
|
func (c *Conn) handleWho(
|
|
ctx context.Context,
|
|
msg *Message,
|
|
) {
|
|
if len(msg.Params) < 1 {
|
|
c.sendNumeric(
|
|
irc.RplEndOfWho, "*", "End of /WHO list",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
target := msg.Params[0]
|
|
|
|
if !strings.HasPrefix(target, "#") {
|
|
// WHO for a nick.
|
|
c.whoNick(ctx, target)
|
|
|
|
return
|
|
}
|
|
|
|
chID, err := c.database.GetChannelByName(ctx, target)
|
|
if err != nil {
|
|
c.sendNumeric(
|
|
irc.RplEndOfWho, target, "End of /WHO list",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
members, err := c.database.ChannelMembers(ctx, chID)
|
|
if err != nil {
|
|
c.sendNumeric(
|
|
irc.RplEndOfWho, target, "End of /WHO list",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
for _, member := range members {
|
|
flags := "H"
|
|
if member.IsOperator {
|
|
flags += "@"
|
|
} else if member.IsVoiced {
|
|
flags += "+"
|
|
}
|
|
|
|
c.sendNumeric(
|
|
irc.RplWhoReply,
|
|
target, member.Username, member.Hostname,
|
|
c.serverSfx, member.Nick, flags,
|
|
"0 "+member.Nick,
|
|
)
|
|
}
|
|
|
|
c.sendNumeric(
|
|
irc.RplEndOfWho, target, "End of /WHO list",
|
|
)
|
|
}
|
|
|
|
// whoNick sends WHO reply for a single nick.
|
|
func (c *Conn) whoNick(ctx context.Context, nick string) {
|
|
targetID, err := c.database.GetSessionByNick(ctx, nick)
|
|
if err != nil {
|
|
c.sendNumeric(
|
|
irc.RplEndOfWho, nick, "End of /WHO list",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
hostInfo, _ := c.database.GetSessionHostInfo(
|
|
ctx, targetID,
|
|
)
|
|
|
|
username := nick
|
|
hostname := "*"
|
|
|
|
if hostInfo != nil {
|
|
username = hostInfo.Username
|
|
hostname = hostInfo.Hostname
|
|
}
|
|
|
|
c.sendNumeric(
|
|
irc.RplWhoReply,
|
|
"*", username, hostname,
|
|
c.serverSfx, nick, "H",
|
|
"0 "+nick,
|
|
)
|
|
c.sendNumeric(
|
|
irc.RplEndOfWho, nick, "End of /WHO list",
|
|
)
|
|
}
|
|
|
|
// handleLusers replies with server statistics.
|
|
func (c *Conn) handleLusers(ctx context.Context) {
|
|
c.deliverLusers(ctx)
|
|
}
|
|
|
|
// handleOper handles the OPER command via the shared
|
|
// service layer.
|
|
func (c *Conn) handleOper(
|
|
ctx context.Context,
|
|
msg *Message,
|
|
) {
|
|
if len(msg.Params) < 2 { //nolint:mnd
|
|
c.sendNumeric(
|
|
irc.ErrNeedMoreParams,
|
|
"OPER", "Not enough parameters",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
err := c.svc.Oper(
|
|
ctx, c.sessionID,
|
|
msg.Params[0], msg.Params[1],
|
|
)
|
|
if err != nil {
|
|
c.sendIRCError(err)
|
|
|
|
return
|
|
}
|
|
|
|
c.sendNumeric(
|
|
irc.RplYoureOper,
|
|
"You are now an IRC operator",
|
|
)
|
|
}
|
|
|
|
// handleAway sets or clears the AWAY status via the
|
|
// shared service layer.
|
|
func (c *Conn) handleAway(
|
|
ctx context.Context,
|
|
msg *Message,
|
|
) {
|
|
message := ""
|
|
if len(msg.Params) > 0 {
|
|
message = msg.Params[0]
|
|
}
|
|
|
|
cleared, err := c.svc.SetAway(
|
|
ctx, c.sessionID, message,
|
|
)
|
|
if err != nil {
|
|
c.log.Error("set away failed", "error", err)
|
|
|
|
return
|
|
}
|
|
|
|
if cleared {
|
|
c.sendNumeric(
|
|
irc.RplUnaway,
|
|
"You are no longer marked as being away",
|
|
)
|
|
} else {
|
|
c.sendNumeric(
|
|
irc.RplNowAway,
|
|
"You have been marked as being away",
|
|
)
|
|
}
|
|
}
|
|
|
|
// handleKick kicks a user from a channel via the shared
|
|
// service layer.
|
|
func (c *Conn) handleKick(
|
|
ctx context.Context,
|
|
msg *Message,
|
|
) {
|
|
if len(msg.Params) < 2 { //nolint:mnd
|
|
c.sendNumeric(
|
|
irc.ErrNeedMoreParams,
|
|
"KICK", "Not enough parameters",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
channel := msg.Params[0]
|
|
targetNick := msg.Params[1]
|
|
|
|
reason := targetNick
|
|
if len(msg.Params) > 2 { //nolint:mnd
|
|
reason = msg.Params[2]
|
|
}
|
|
|
|
err := c.svc.KickUser(
|
|
ctx, c.sessionID, c.nick,
|
|
channel, targetNick, reason,
|
|
)
|
|
if err != nil {
|
|
c.sendIRCError(err)
|
|
|
|
return
|
|
}
|
|
|
|
// Echo KICK on wire.
|
|
c.send(FormatMessage(
|
|
c.hostmask(), "KICK", channel, targetNick, reason,
|
|
))
|
|
}
|
|
|
|
// handlePassPostReg handles PASS after registration (for
|
|
// setting a session password).
|
|
func (c *Conn) handlePassPostReg(
|
|
ctx context.Context,
|
|
msg *Message,
|
|
) {
|
|
if len(msg.Params) < 1 {
|
|
c.sendNumeric(
|
|
irc.ErrNeedMoreParams,
|
|
"PASS", "Not enough parameters",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
password := msg.Params[0]
|
|
if len(password) < minPasswordLen {
|
|
c.sendFromServer("NOTICE", c.nick,
|
|
"Password must be at least 8 characters",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
c.setPassword(ctx, password)
|
|
|
|
c.sendFromServer("NOTICE", c.nick,
|
|
"Password set. You can reconnect using "+
|
|
"PASS <password> with your nick.",
|
|
)
|
|
}
|
|
|
|
// handleInvite handles the INVITE command.
|
|
func (c *Conn) handleInvite(
|
|
ctx context.Context,
|
|
msg *Message,
|
|
) {
|
|
if len(msg.Params) < 2 { //nolint:mnd
|
|
c.sendNumeric(
|
|
irc.ErrNeedMoreParams,
|
|
"INVITE", "Not enough parameters",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
targetNick := msg.Params[0]
|
|
channel := msg.Params[1]
|
|
|
|
chID, err := c.database.GetChannelByName(ctx, channel)
|
|
if err != nil {
|
|
c.sendNumeric(
|
|
irc.ErrNoSuchChannel,
|
|
channel, "No such channel",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
isMember, _ := c.database.IsChannelMember(
|
|
ctx, chID, c.sessionID,
|
|
)
|
|
if !isMember {
|
|
c.sendNumeric(
|
|
irc.ErrNotOnChannel,
|
|
channel, "You're not on that channel",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
targetID, err := c.database.GetSessionByNick(
|
|
ctx, targetNick,
|
|
)
|
|
if err != nil {
|
|
c.sendNumeric(
|
|
irc.ErrNoSuchNick,
|
|
targetNick, "No such nick",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
c.sendNumeric(
|
|
irc.RplInviting, targetNick, channel,
|
|
)
|
|
|
|
// Send INVITE notice to target via service fan-out.
|
|
body, _ := json.Marshal( //nolint:errchkjson
|
|
[]string{"You have been invited to " + channel},
|
|
)
|
|
|
|
_, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
|
ctx, "INVITE", c.nick, targetNick,
|
|
nil, body, nil, []int64{targetID},
|
|
)
|
|
}
|
|
|
|
// handleUserhost replies with USERHOST info.
|
|
func (c *Conn) handleUserhost(
|
|
ctx context.Context,
|
|
msg *Message,
|
|
) {
|
|
if len(msg.Params) < 1 {
|
|
return
|
|
}
|
|
|
|
replies := make([]string, 0, len(msg.Params))
|
|
|
|
for _, nick := range msg.Params {
|
|
sid, err := c.database.GetSessionByNick(ctx, nick)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
hostInfo, _ := c.database.GetSessionHostInfo(
|
|
ctx, sid,
|
|
)
|
|
|
|
host := "*"
|
|
if hostInfo != nil {
|
|
host = hostInfo.Hostname
|
|
}
|
|
|
|
isOper, _ := c.database.IsSessionOper(ctx, sid)
|
|
|
|
operStar := ""
|
|
if isOper {
|
|
operStar = "*"
|
|
}
|
|
|
|
replies = append(
|
|
replies,
|
|
nick+operStar+"=+"+nick+"@"+host,
|
|
)
|
|
}
|
|
|
|
c.sendNumeric(
|
|
irc.RplUserHost,
|
|
strings.Join(replies, " "),
|
|
)
|
|
}
|
|
|
|
// handleVersion replies with the server version string.
|
|
func (c *Conn) handleVersion() {
|
|
c.sendNumeric(
|
|
irc.RplVersion,
|
|
c.svc.ServerVersion()+".", c.cfg.ServerName,
|
|
"",
|
|
)
|
|
}
|
|
|
|
// handleAdmin replies with server admin info.
|
|
func (c *Conn) handleAdmin() {
|
|
srvName := c.cfg.ServerName
|
|
|
|
c.sendNumeric(
|
|
irc.RplAdminMe,
|
|
srvName, "Administrative info",
|
|
)
|
|
|
|
c.sendNumeric(
|
|
irc.RplAdminLoc1,
|
|
"neoirc server",
|
|
)
|
|
|
|
c.sendNumeric(
|
|
irc.RplAdminLoc2,
|
|
"IRC over HTTP",
|
|
)
|
|
|
|
c.sendNumeric(
|
|
irc.RplAdminEmail,
|
|
"admin@"+srvName,
|
|
)
|
|
}
|
|
|
|
// handleInfo replies with server software info.
|
|
func (c *Conn) handleInfo() {
|
|
for _, line := range c.svc.InfoLines() {
|
|
c.sendNumeric(irc.RplInfo, line)
|
|
}
|
|
|
|
c.sendNumeric(
|
|
irc.RplEndOfInfo,
|
|
"End of /INFO list",
|
|
)
|
|
}
|
|
|
|
// handleTime replies with the server's current time.
|
|
func (c *Conn) handleTime() {
|
|
srvName := c.cfg.ServerName
|
|
|
|
c.sendNumeric(
|
|
irc.RplTime,
|
|
srvName, time.Now().Format(time.RFC1123),
|
|
)
|
|
}
|
|
|
|
// handleKillCmd forcibly disconnects a target user (oper
|
|
// only).
|
|
func (c *Conn) handleKillCmd(
|
|
ctx context.Context,
|
|
msg *Message,
|
|
) {
|
|
isOper, err := c.database.IsSessionOper(
|
|
ctx, c.sessionID,
|
|
)
|
|
if err != nil || !isOper {
|
|
c.sendNumeric(
|
|
irc.ErrNoPrivileges,
|
|
"Permission Denied- "+
|
|
"You're not an IRC operator",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if len(msg.Params) < 1 {
|
|
c.sendNumeric(
|
|
irc.ErrNeedMoreParams,
|
|
"KILL", "Not enough parameters",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
targetNick := msg.Params[0]
|
|
|
|
reason := "KILLed"
|
|
if len(msg.Params) > 1 {
|
|
reason = msg.Params[1]
|
|
}
|
|
|
|
killerNick := c.currentNick()
|
|
|
|
if strings.EqualFold(targetNick, killerNick) {
|
|
c.sendNumeric(
|
|
irc.ErrCantKillServer,
|
|
"You cannot KILL yourself",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
targetSID, lookupErr := c.database.GetSessionByNick(
|
|
ctx, targetNick,
|
|
)
|
|
if lookupErr != nil {
|
|
c.sendNumeric(
|
|
irc.ErrNoSuchNick,
|
|
targetNick, "No such nick/channel",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
quitReason := "Killed (" + killerNick +
|
|
" (" + reason + "))"
|
|
|
|
// KillSession broadcasts the QUIT, deletes the session
|
|
// and disconnects the victim's wire connection.
|
|
c.svc.KillSession(
|
|
ctx, targetSID, targetNick, quitReason,
|
|
)
|
|
}
|
|
|
|
// handleWallopsCmd broadcasts to all +w users (oper only).
|
|
func (c *Conn) handleWallopsCmd(
|
|
ctx context.Context,
|
|
msg *Message,
|
|
) {
|
|
isOper, err := c.database.IsSessionOper(
|
|
ctx, c.sessionID,
|
|
)
|
|
if err != nil || !isOper {
|
|
c.sendNumeric(
|
|
irc.ErrNoPrivileges,
|
|
"Permission Denied- "+
|
|
"You're not an IRC operator",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if len(msg.Params) < 1 {
|
|
c.sendNumeric(
|
|
irc.ErrNeedMoreParams,
|
|
"WALLOPS", "Not enough parameters",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
message := msg.Params[0]
|
|
|
|
wallopsSIDs, wallErr := c.database.
|
|
GetWallopsSessionIDs(ctx)
|
|
if wallErr != nil {
|
|
c.log.Error(
|
|
"get wallops sessions failed",
|
|
"error", wallErr,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if len(wallopsSIDs) > 0 {
|
|
body, mErr := json.Marshal([]string{message})
|
|
if mErr != nil {
|
|
return
|
|
}
|
|
|
|
_, _, _ = c.svc.FanOut(
|
|
ctx, irc.CmdWallops, c.currentNick(), "*",
|
|
nil, body, nil, wallopsSIDs,
|
|
)
|
|
}
|
|
}
|