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.
577 lines
12 KiB
Go
577 lines
12 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"sneak.berlin/go/neoirc/internal/db"
|
|
"sneak.berlin/go/neoirc/internal/service"
|
|
"sneak.berlin/go/neoirc/pkg/irc"
|
|
)
|
|
|
|
// maxUserhostNicks is the maximum number of nicks allowed
|
|
// in a single USERHOST query (RFC 2812).
|
|
const maxUserhostNicks = 5
|
|
|
|
// dispatchBodyOnlyCommand routes commands that take
|
|
// (writer, request, sessionID, clientID, nick, bodyLines).
|
|
func (hdlr *Handlers) dispatchBodyOnlyCommand(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
sessionID, clientID int64,
|
|
nick, command string,
|
|
bodyLines func() []string,
|
|
) {
|
|
switch command {
|
|
case irc.CmdAway:
|
|
hdlr.handleAway(
|
|
writer, request,
|
|
sessionID, clientID, nick, bodyLines,
|
|
)
|
|
case irc.CmdNick:
|
|
hdlr.handleNick(
|
|
writer, request,
|
|
sessionID, clientID, nick, bodyLines,
|
|
)
|
|
case irc.CmdPass:
|
|
hdlr.handlePass(
|
|
writer, request,
|
|
sessionID, clientID, nick, bodyLines,
|
|
)
|
|
case irc.CmdInvite:
|
|
hdlr.handleInvite(
|
|
writer, request,
|
|
sessionID, clientID, nick, bodyLines,
|
|
)
|
|
}
|
|
}
|
|
|
|
// dispatchOperCommand routes oper-related commands (OPER,
|
|
// KILL, WALLOPS) to their handlers.
|
|
func (hdlr *Handlers) dispatchOperCommand(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
sessionID, clientID int64,
|
|
nick, command string,
|
|
bodyLines func() []string,
|
|
) {
|
|
switch command {
|
|
case irc.CmdOper:
|
|
hdlr.handleOper(
|
|
writer, request,
|
|
sessionID, clientID, nick, bodyLines,
|
|
)
|
|
case irc.CmdKill:
|
|
hdlr.handleKill(
|
|
writer, request,
|
|
sessionID, clientID, nick, bodyLines,
|
|
)
|
|
case irc.CmdWallops:
|
|
hdlr.handleWallops(
|
|
writer, request,
|
|
sessionID, clientID, nick, bodyLines,
|
|
)
|
|
}
|
|
}
|
|
|
|
// handleUserhost handles the USERHOST command.
|
|
// Returns user@host info for up to 5 nicks.
|
|
func (hdlr *Handlers) handleUserhost(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
sessionID, clientID int64,
|
|
nick string,
|
|
bodyLines func() []string,
|
|
) {
|
|
ctx := request.Context()
|
|
|
|
lines := bodyLines()
|
|
if len(lines) == 0 {
|
|
hdlr.respondIRCError(
|
|
writer, request, clientID, sessionID,
|
|
irc.ErrNeedMoreParams, nick,
|
|
[]string{irc.CmdUserhost},
|
|
"Not enough parameters",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// Limit to 5 nicks per RFC 2812.
|
|
nicks := lines
|
|
if len(nicks) > maxUserhostNicks {
|
|
nicks = nicks[:maxUserhostNicks]
|
|
}
|
|
|
|
infos, err := hdlr.params.Database.GetUserhostInfo(
|
|
ctx, nicks,
|
|
)
|
|
if err != nil {
|
|
hdlr.log.Error(
|
|
"userhost query failed", "error", err,
|
|
)
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"internal error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
replyStr := hdlr.buildUserhostReply(infos)
|
|
|
|
hdlr.enqueueNumeric(
|
|
ctx, clientID, irc.RplUserHost, nick, nil,
|
|
replyStr,
|
|
)
|
|
|
|
hdlr.broker.Notify(sessionID)
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{"status": "ok"},
|
|
http.StatusOK)
|
|
}
|
|
|
|
// buildUserhostReply builds the RPL_USERHOST reply
|
|
// string per RFC 2812.
|
|
func (hdlr *Handlers) buildUserhostReply(
|
|
infos []db.UserhostInfo,
|
|
) string {
|
|
replies := make([]string, 0, len(infos))
|
|
|
|
for idx := range infos {
|
|
info := &infos[idx]
|
|
|
|
username := info.Username
|
|
if username == "" {
|
|
username = info.Nick
|
|
}
|
|
|
|
hostname := info.Hostname
|
|
if hostname == "" {
|
|
hostname = hdlr.serverName()
|
|
}
|
|
|
|
operStar := ""
|
|
if info.IsOper {
|
|
operStar = "*"
|
|
}
|
|
|
|
awayPrefix := "+"
|
|
if info.AwayMessage != "" {
|
|
awayPrefix = "-"
|
|
}
|
|
|
|
replies = append(replies,
|
|
info.Nick+operStar+"="+
|
|
awayPrefix+username+"@"+hostname,
|
|
)
|
|
}
|
|
|
|
return strings.Join(replies, " ")
|
|
}
|
|
|
|
// handleVersion handles the VERSION command.
|
|
// Returns the server version string.
|
|
func (hdlr *Handlers) handleVersion(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
sessionID, clientID int64,
|
|
nick string,
|
|
) {
|
|
ctx := request.Context()
|
|
srvName := hdlr.serverName()
|
|
version := hdlr.svc.ServerVersion()
|
|
|
|
// 351 RPL_VERSION
|
|
hdlr.enqueueNumeric(
|
|
ctx, clientID, irc.RplVersion, nick,
|
|
[]string{version + ".", srvName},
|
|
"",
|
|
)
|
|
|
|
hdlr.broker.Notify(sessionID)
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{"status": "ok"},
|
|
http.StatusOK)
|
|
}
|
|
|
|
// handleAdmin handles the ADMIN command.
|
|
// Returns server admin contact info.
|
|
func (hdlr *Handlers) handleAdmin(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
sessionID, clientID int64,
|
|
nick string,
|
|
) {
|
|
ctx := request.Context()
|
|
srvName := hdlr.serverName()
|
|
|
|
// 256 RPL_ADMINME
|
|
hdlr.enqueueNumeric(
|
|
ctx, clientID, irc.RplAdminMe, nick,
|
|
[]string{srvName},
|
|
"Administrative info",
|
|
)
|
|
|
|
// 257 RPL_ADMINLOC1
|
|
hdlr.enqueueNumeric(
|
|
ctx, clientID, irc.RplAdminLoc1, nick, nil,
|
|
"neoirc server",
|
|
)
|
|
|
|
// 258 RPL_ADMINLOC2
|
|
hdlr.enqueueNumeric(
|
|
ctx, clientID, irc.RplAdminLoc2, nick, nil,
|
|
"IRC over HTTP",
|
|
)
|
|
|
|
// 259 RPL_ADMINEMAIL
|
|
hdlr.enqueueNumeric(
|
|
ctx, clientID, irc.RplAdminEmail, nick, nil,
|
|
"admin@"+srvName,
|
|
)
|
|
|
|
hdlr.broker.Notify(sessionID)
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{"status": "ok"},
|
|
http.StatusOK)
|
|
}
|
|
|
|
// handleInfo handles the INFO command.
|
|
// Returns server software information.
|
|
func (hdlr *Handlers) handleInfo(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
sessionID, clientID int64,
|
|
nick string,
|
|
) {
|
|
ctx := request.Context()
|
|
|
|
for _, line := range hdlr.svc.InfoLines() {
|
|
// 371 RPL_INFO
|
|
hdlr.enqueueNumeric(
|
|
ctx, clientID, irc.RplInfo, nick, nil,
|
|
line,
|
|
)
|
|
}
|
|
|
|
// 374 RPL_ENDOFINFO
|
|
hdlr.enqueueNumeric(
|
|
ctx, clientID, irc.RplEndOfInfo, nick, nil,
|
|
"End of /INFO list",
|
|
)
|
|
|
|
hdlr.broker.Notify(sessionID)
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{"status": "ok"},
|
|
http.StatusOK)
|
|
}
|
|
|
|
// handleTime handles the TIME command.
|
|
// Returns the server's local time in RFC format.
|
|
func (hdlr *Handlers) handleTime(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
sessionID, clientID int64,
|
|
nick string,
|
|
) {
|
|
ctx := request.Context()
|
|
srvName := hdlr.serverName()
|
|
|
|
// 391 RPL_TIME
|
|
hdlr.enqueueNumeric(
|
|
ctx, clientID, irc.RplTime, nick,
|
|
[]string{srvName},
|
|
time.Now().Format(time.RFC1123),
|
|
)
|
|
|
|
hdlr.broker.Notify(sessionID)
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{"status": "ok"},
|
|
http.StatusOK)
|
|
}
|
|
|
|
// handleKill handles the KILL command.
|
|
// Forcibly disconnects a user (oper only).
|
|
func (hdlr *Handlers) handleKill(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
sessionID, clientID int64,
|
|
nick string,
|
|
bodyLines func() []string,
|
|
) {
|
|
ctx := request.Context()
|
|
|
|
// Check oper status.
|
|
isOper, err := hdlr.params.Database.IsSessionOper(
|
|
ctx, sessionID,
|
|
)
|
|
if err != nil || !isOper {
|
|
hdlr.respondIRCError(
|
|
writer, request, clientID, sessionID,
|
|
irc.ErrNoPrivileges, nick, nil,
|
|
"Permission Denied- You're not an IRC operator",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
lines := bodyLines()
|
|
|
|
var targetNick string
|
|
if len(lines) > 0 {
|
|
targetNick = strings.TrimSpace(lines[0])
|
|
}
|
|
|
|
if targetNick == "" {
|
|
hdlr.respondIRCError(
|
|
writer, request, clientID, sessionID,
|
|
irc.ErrNeedMoreParams, nick,
|
|
[]string{irc.CmdKill},
|
|
"Not enough parameters",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
reason := "KILLed"
|
|
if len(lines) > 1 {
|
|
reason = lines[1]
|
|
}
|
|
|
|
targetSID, lookupErr := hdlr.params.Database.
|
|
GetSessionByNick(ctx, targetNick)
|
|
if lookupErr != nil {
|
|
hdlr.respondIRCError(
|
|
writer, request, clientID, sessionID,
|
|
irc.ErrNoSuchNick, nick,
|
|
[]string{targetNick},
|
|
"No such nick/channel",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// Do not allow killing yourself.
|
|
if targetSID == sessionID {
|
|
hdlr.respondIRCError(
|
|
writer, request, clientID, sessionID,
|
|
irc.ErrCantKillServer, nick, nil,
|
|
"You cannot KILL yourself",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
quitReason := "Killed (" + nick + " (" + reason + "))"
|
|
|
|
// KillSession broadcasts the QUIT, deletes the session
|
|
// and disconnects the victim's wire connection if it
|
|
// holds one.
|
|
hdlr.svc.KillSession(
|
|
ctx, targetSID, targetNick, quitReason,
|
|
)
|
|
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{"status": "ok"},
|
|
http.StatusOK)
|
|
}
|
|
|
|
// handleWallops handles the WALLOPS command.
|
|
// Broadcasts a message to all users with +w usermode
|
|
// (oper only).
|
|
func (hdlr *Handlers) handleWallops(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
sessionID, clientID int64,
|
|
nick string,
|
|
bodyLines func() []string,
|
|
) {
|
|
ctx := request.Context()
|
|
|
|
// Check oper status.
|
|
isOper, err := hdlr.params.Database.IsSessionOper(
|
|
ctx, sessionID,
|
|
)
|
|
if err != nil || !isOper {
|
|
hdlr.respondIRCError(
|
|
writer, request, clientID, sessionID,
|
|
irc.ErrNoPrivileges, nick, nil,
|
|
"Permission Denied- You're not an IRC operator",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
lines := bodyLines()
|
|
if len(lines) == 0 {
|
|
hdlr.respondIRCError(
|
|
writer, request, clientID, sessionID,
|
|
irc.ErrNeedMoreParams, nick,
|
|
[]string{irc.CmdWallops},
|
|
"Not enough parameters",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
message := strings.Join(lines, " ")
|
|
|
|
wallopsSIDs, err := hdlr.params.Database.
|
|
GetWallopsSessionIDs(ctx)
|
|
if err != nil {
|
|
hdlr.log.Error(
|
|
"get wallops sessions failed", "error", err,
|
|
)
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"internal error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if len(wallopsSIDs) > 0 {
|
|
body, mErr := json.Marshal([]string{message})
|
|
if mErr != nil {
|
|
hdlr.log.Error(
|
|
"marshal wallops body", "error", mErr,
|
|
)
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"internal error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
_ = hdlr.fanOutSilent(
|
|
request, irc.CmdWallops, nick, "*",
|
|
json.RawMessage(body), wallopsSIDs,
|
|
)
|
|
}
|
|
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{"status": "ok"},
|
|
http.StatusOK)
|
|
}
|
|
|
|
// handleUserMode handles user mode queries and changes
|
|
// (e.g., MODE nick, MODE nick +w). Delegates to the
|
|
// shared service.ApplyUserMode / service.QueryUserMode so
|
|
// that mode string processing is identical for both the
|
|
// HTTP API and IRC wire protocol.
|
|
func (hdlr *Handlers) handleUserMode(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
sessionID, clientID int64,
|
|
nick, target string,
|
|
bodyLines func() []string,
|
|
) {
|
|
ctx := request.Context()
|
|
|
|
// Users can only query or change their own modes. The
|
|
// check is above the query/change split so that both
|
|
// forms are rejected, and uses EqualFold because IRC
|
|
// nicks are case-insensitive — matching the wire path
|
|
// in ircserver.handleUserMode.
|
|
if target != "" && !strings.EqualFold(target, nick) {
|
|
hdlr.respondIRCError(
|
|
writer, request, clientID, sessionID,
|
|
irc.ErrUsersDoNotMatch, nick, nil,
|
|
"Can't change mode for other users",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
lines := bodyLines()
|
|
|
|
// Mode change requested.
|
|
if len(lines) > 0 {
|
|
hdlr.changeUserMode(
|
|
writer, request,
|
|
sessionID, clientID, nick, lines[0],
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// Mode query — delegate to shared service.
|
|
modeStr, err := hdlr.svc.QueryUserMode(ctx, sessionID)
|
|
if err != nil {
|
|
hdlr.log.Error(
|
|
"query user mode failed", "error", err,
|
|
)
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"internal error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
hdlr.enqueueNumeric(
|
|
ctx, clientID, irc.RplUmodeIs, nick, nil,
|
|
modeStr,
|
|
)
|
|
hdlr.broker.Notify(sessionID)
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{"status": "ok"},
|
|
http.StatusOK)
|
|
}
|
|
|
|
// changeUserMode applies a mode string to the caller's own
|
|
// session. The caller has already verified that the target
|
|
// nick is the caller's own.
|
|
func (hdlr *Handlers) changeUserMode(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
sessionID, clientID int64,
|
|
nick, modeStr string,
|
|
) {
|
|
ctx := request.Context()
|
|
|
|
newModes, err := hdlr.svc.ApplyUserMode(
|
|
ctx, sessionID, modeStr,
|
|
)
|
|
if err != nil {
|
|
var ircErr *service.IRCError
|
|
if errors.As(err, &ircErr) {
|
|
hdlr.respondIRCError(
|
|
writer, request,
|
|
clientID, sessionID,
|
|
ircErr.Code, nick, ircErr.Params,
|
|
ircErr.Message,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"internal error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
hdlr.enqueueNumeric(
|
|
ctx, clientID, irc.RplUmodeIs, nick, nil,
|
|
newModes,
|
|
)
|
|
|
|
hdlr.broker.Notify(sessionID)
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{"status": "ok"},
|
|
http.StatusOK)
|
|
}
|