feat: implement Tier 3 utility IRC commands (USERHOST, VERSION, ADMIN, INFO, TIME, KILL, WALLOPS) (closes #87) #96
@@ -2324,6 +2324,15 @@ IRC_LISTEN_ADDR=
|
|||||||
operator status (`@`).
|
operator status (`@`).
|
||||||
- **Channel modes**: `+m` (moderated), `+t` (topic lock), `+o` (operator),
|
- **Channel modes**: `+m` (moderated), `+t` (topic lock), `+o` (operator),
|
||||||
`+v` (voice)
|
`+v` (voice)
|
||||||
|
- **User modes**: `+o` (operator, set only via `OPER`), `+w` (receives
|
||||||
|
`WALLOPS`). `MODE` for any nick other than your own is rejected with
|
||||||
|
`ERR_USERSDONTMATCH` (502), for both queries and changes. Nick comparison
|
||||||
|
is case-insensitive.
|
||||||
|
- **KILL**: an operator's `KILL` broadcasts the victim's `QUIT` to its channel
|
||||||
|
peers, deletes its session, and then sends the victim a `KILL` and
|
||||||
|
`ERROR :Closing Link` before closing its socket. This applies to victims on
|
||||||
|
the IRC listener regardless of whether the `KILL` arrived over IRC or the
|
||||||
|
HTTP API.
|
||||||
|
|
||||||
### Bridge to HTTP API
|
### Bridge to HTTP API
|
||||||
|
|
||||||
|
|||||||
+11
-2
@@ -7,6 +7,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -2509,7 +2510,9 @@ type UserhostInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetUserhostInfo returns USERHOST info for the given
|
// GetUserhostInfo returns USERHOST info for the given
|
||||||
// nicks. Only nicks that exist are returned.
|
// nicks. Nicks with no session are omitted from the
|
||||||
|
// result; any other database failure is returned, because
|
||||||
|
// an unreadable row is not the same as an absent one.
|
||||||
func (database *Database) GetUserhostInfo(
|
func (database *Database) GetUserhostInfo(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nicks []string,
|
nicks []string,
|
||||||
@@ -2534,7 +2537,13 @@ func (database *Database) GetUserhostInfo(
|
|||||||
&info.IsOper, &info.AwayMessage,
|
&info.IsOper, &info.AwayMessage,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue // nick not found, skip
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
continue // nick not online
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"userhost lookup %q: %w", nick, err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
results = append(results, info)
|
results = append(results, info)
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ func newTestHandlers(
|
|||||||
Config: cfg,
|
Config: cfg,
|
||||||
Database: database,
|
Database: database,
|
||||||
Broker: brk,
|
Broker: brk,
|
||||||
|
Globals: globs,
|
||||||
})
|
})
|
||||||
|
|
||||||
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
|
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ func (hdlr *Handlers) handleVersion(
|
|||||||
) {
|
) {
|
||||||
ctx := request.Context()
|
ctx := request.Context()
|
||||||
srvName := hdlr.serverName()
|
srvName := hdlr.serverName()
|
||||||
version := hdlr.serverVersion()
|
version := hdlr.svc.ServerVersion()
|
||||||
|
|
||||||
// 351 RPL_VERSION
|
// 351 RPL_VERSION
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
@@ -250,18 +250,8 @@ func (hdlr *Handlers) handleInfo(
|
|||||||
nick string,
|
nick string,
|
||||||
) {
|
) {
|
||||||
ctx := request.Context()
|
ctx := request.Context()
|
||||||
version := hdlr.serverVersion()
|
|
||||||
|
|
||||||
infoLines := []string{
|
for _, line := range hdlr.svc.InfoLines() {
|
||||||
"neoirc — IRC semantics over HTTP",
|
|
||||||
"Version: " + version,
|
|
||||||
"Written in Go",
|
|
||||||
"Started: " +
|
|
||||||
hdlr.params.Globals.StartTime.
|
|
||||||
Format(time.RFC1123),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, line := range infoLines {
|
|
||||||
// 371 RPL_INFO
|
// 371 RPL_INFO
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplInfo, nick, nil,
|
ctx, clientID, irc.RplInfo, nick, nil,
|
||||||
@@ -379,9 +369,11 @@ func (hdlr *Handlers) handleKill(
|
|||||||
|
|
||||||
quitReason := "Killed (" + nick + " (" + reason + "))"
|
quitReason := "Killed (" + nick + " (" + reason + "))"
|
||||||
|
|
||||||
hdlr.svc.BroadcastQuit(
|
// KillSession broadcasts the QUIT, deletes the session
|
||||||
request.Context(), targetSID,
|
// and disconnects the victim's wire connection if it
|
||||||
targetNick, quitReason,
|
// holds one.
|
||||||
|
hdlr.svc.KillSession(
|
||||||
|
ctx, targetSID, targetNick, quitReason,
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
@@ -484,12 +476,12 @@ func (hdlr *Handlers) handleUserMode(
|
|||||||
) {
|
) {
|
||||||
ctx := request.Context()
|
ctx := request.Context()
|
||||||
|
|
||||||
lines := bodyLines()
|
// Users can only query or change their own modes. The
|
||||||
|
// check is above the query/change split so that both
|
||||||
// Mode change requested.
|
// forms are rejected, and uses EqualFold because IRC
|
||||||
if len(lines) > 0 {
|
// nicks are case-insensitive — matching the wire path
|
||||||
// Users can only change their own modes.
|
// in ircserver.handleUserMode.
|
||||||
if target != nick && target != "" {
|
if target != "" && !strings.EqualFold(target, nick) {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrUsersDoNotMatch, nick, nil,
|
irc.ErrUsersDoNotMatch, nick, nil,
|
||||||
@@ -499,8 +491,56 @@ func (hdlr *Handlers) handleUserMode(
|
|||||||
return
|
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(
|
newModes, err := hdlr.svc.ApplyUserMode(
|
||||||
ctx, sessionID, lines[0],
|
ctx, sessionID, modeStr,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var ircErr *service.IRCError
|
var ircErr *service.IRCError
|
||||||
@@ -533,19 +573,4 @@ func (hdlr *Handlers) handleUserMode(
|
|||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
map[string]string{"status": "ok"},
|
map[string]string{"status": "ok"},
|
||||||
http.StatusOK)
|
http.StatusOK)
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mode query — delegate to shared service.
|
|
||||||
modeStr := hdlr.svc.QueryUserMode(ctx, sessionID)
|
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplUmodeIs, nick, nil,
|
|
||||||
modeStr,
|
|
||||||
)
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -959,6 +959,95 @@ func TestUserModeCannotChangeOtherUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestUserModeCannotQueryOtherUser covers the MODE query
|
||||||
|
// form (no body). Without a body there is no mode change
|
||||||
|
// to reject, so a missing target check here silently
|
||||||
|
// answers with the *requester's* own modes.
|
||||||
|
func TestUserModeCannotQueryOtherUser(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
// Give the other user a mode the querier does not have,
|
||||||
|
// so leaking their modes would be visible.
|
||||||
|
otherToken := tserver.createSession("target")
|
||||||
|
_, otherLast := tserver.pollMessages(otherToken, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(otherToken, map[string]any{
|
||||||
|
commandKey: "MODE",
|
||||||
|
toKey: "target",
|
||||||
|
bodyKey: []string{"+w"},
|
||||||
|
})
|
||||||
|
tserver.pollMessages(otherToken, otherLast)
|
||||||
|
|
||||||
|
token := tserver.createSession("querier")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
// Query another user's modes — no body.
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "MODE",
|
||||||
|
toKey: "target",
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Expect 502 ERR_USERSDONTMATCH.
|
||||||
|
if !findNumeric(msgs, "502") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_USERSDONTMATCH (502), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And definitely not a mode reply.
|
||||||
|
if findNumeric(msgs, "221") {
|
||||||
|
t.Fatalf(
|
||||||
|
"MODE query for another user leaked "+
|
||||||
|
"RPL_UMODEIS (221): %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUserModeOwnNickIsCaseInsensitive covers the same-nick
|
||||||
|
// comparison. IRC nicks are case-insensitive, so a user
|
||||||
|
// must be able to address their own modes in any case —
|
||||||
|
// and the wire path already allows it.
|
||||||
|
func TestUserModeOwnNickIsCaseInsensitive(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
token := tserver.createSession("mixedcase")
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "MODE",
|
||||||
|
toKey: "MixedCase",
|
||||||
|
bodyKey: []string{"+w"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
if findNumeric(msgs, "502") {
|
||||||
|
t.Fatalf(
|
||||||
|
"own nick in different case rejected with "+
|
||||||
|
"ERR_USERSDONTMATCH (502): %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := findNumericWithParams(msgs, "221")
|
||||||
|
if msg == nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_UMODEIS (221), got %v", msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := getNumericBody(msg)
|
||||||
|
if !strings.Contains(body, "w") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 'w' to be set, got %q", body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// getNumericBody extracts the body text from a numeric
|
// getNumericBody extracts the body text from a numeric
|
||||||
// message. The body is stored as a JSON array; this
|
// message. The body is stored as a JSON array; this
|
||||||
// returns the first element.
|
// returns the first element.
|
||||||
|
|||||||
@@ -8,29 +8,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/neoirc/internal/globals"
|
|
||||||
"sneak.berlin/go/neoirc/internal/service"
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
"sneak.berlin/go/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// versionString returns the server version for IRC
|
|
||||||
// responses, falling back to "neoirc-dev" when globals
|
|
||||||
// are not set (e.g. during tests).
|
|
||||||
func versionString() string {
|
|
||||||
name := globals.Appname
|
|
||||||
ver := globals.Version
|
|
||||||
|
|
||||||
if name == "" {
|
|
||||||
name = "neoirc"
|
|
||||||
}
|
|
||||||
|
|
||||||
if ver == "" {
|
|
||||||
ver = "dev"
|
|
||||||
}
|
|
||||||
|
|
||||||
return name + "-" + ver
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendIRCError maps a service.IRCError to an IRC numeric
|
// sendIRCError maps a service.IRCError to an IRC numeric
|
||||||
// reply on the wire.
|
// reply on the wire.
|
||||||
func (c *Conn) sendIRCError(err error) {
|
func (c *Conn) sendIRCError(err error) {
|
||||||
@@ -368,7 +349,10 @@ func (c *Conn) handleQuit(msg *Message) {
|
|||||||
|
|
||||||
c.send("ERROR :Closing Link: " + c.hostname +
|
c.send("ERROR :Closing Link: " + c.hostname +
|
||||||
" (Quit: " + reason + ")")
|
" (Quit: " + reason + ")")
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
c.closed = true
|
c.closed = true
|
||||||
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleTopic gets or sets a channel topic via the shared
|
// handleTopic gets or sets a channel topic via the shared
|
||||||
@@ -719,7 +703,7 @@ func (c *Conn) handleUserMode(
|
|||||||
) {
|
) {
|
||||||
target := msg.Params[0]
|
target := msg.Params[0]
|
||||||
|
|
||||||
if !strings.EqualFold(target, c.nick) {
|
if !strings.EqualFold(target, c.currentNick()) {
|
||||||
c.sendNumeric(
|
c.sendNumeric(
|
||||||
irc.ErrUsersDoNotMatch,
|
irc.ErrUsersDoNotMatch,
|
||||||
"Can't change mode for other users",
|
"Can't change mode for other users",
|
||||||
@@ -730,7 +714,21 @@ func (c *Conn) handleUserMode(
|
|||||||
|
|
||||||
// Mode query (no mode string).
|
// Mode query (no mode string).
|
||||||
if len(msg.Params) < 2 { //nolint:mnd
|
if len(msg.Params) < 2 { //nolint:mnd
|
||||||
modes := c.svc.QueryUserMode(ctx, c.sessionID)
|
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)
|
c.sendNumeric(irc.RplUmodeIs, modes)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -1349,22 +1347,16 @@ func (c *Conn) handleUserhost(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleVersion replies with the server version string.
|
// handleVersion replies with the server version string.
|
||||||
func (c *Conn) handleVersion(ctx context.Context) {
|
func (c *Conn) handleVersion() {
|
||||||
_ = ctx
|
|
||||||
|
|
||||||
version := versionString()
|
|
||||||
|
|
||||||
c.sendNumeric(
|
c.sendNumeric(
|
||||||
irc.RplVersion,
|
irc.RplVersion,
|
||||||
version+".", c.cfg.ServerName,
|
c.svc.ServerVersion()+".", c.cfg.ServerName,
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAdmin replies with server admin info.
|
// handleAdmin replies with server admin info.
|
||||||
func (c *Conn) handleAdmin(ctx context.Context) {
|
func (c *Conn) handleAdmin() {
|
||||||
_ = ctx
|
|
||||||
|
|
||||||
srvName := c.cfg.ServerName
|
srvName := c.cfg.ServerName
|
||||||
|
|
||||||
c.sendNumeric(
|
c.sendNumeric(
|
||||||
@@ -1389,16 +1381,8 @@ func (c *Conn) handleAdmin(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleInfo replies with server software info.
|
// handleInfo replies with server software info.
|
||||||
func (c *Conn) handleInfo(ctx context.Context) {
|
func (c *Conn) handleInfo() {
|
||||||
_ = ctx
|
for _, line := range c.svc.InfoLines() {
|
||||||
|
|
||||||
infoLines := []string{
|
|
||||||
"neoirc — IRC semantics over HTTP",
|
|
||||||
"Version: " + versionString(),
|
|
||||||
"Written in Go",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, line := range infoLines {
|
|
||||||
c.sendNumeric(irc.RplInfo, line)
|
c.sendNumeric(irc.RplInfo, line)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1409,9 +1393,7 @@ func (c *Conn) handleInfo(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleTime replies with the server's current time.
|
// handleTime replies with the server's current time.
|
||||||
func (c *Conn) handleTime(ctx context.Context) {
|
func (c *Conn) handleTime() {
|
||||||
_ = ctx
|
|
||||||
|
|
||||||
srvName := c.cfg.ServerName
|
srvName := c.cfg.ServerName
|
||||||
|
|
||||||
c.sendNumeric(
|
c.sendNumeric(
|
||||||
@@ -1455,7 +1437,9 @@ func (c *Conn) handleKillCmd(
|
|||||||
reason = msg.Params[1]
|
reason = msg.Params[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
if targetNick == c.nick {
|
killerNick := c.currentNick()
|
||||||
|
|
||||||
|
if strings.EqualFold(targetNick, killerNick) {
|
||||||
c.sendNumeric(
|
c.sendNumeric(
|
||||||
irc.ErrCantKillServer,
|
irc.ErrCantKillServer,
|
||||||
"You cannot KILL yourself",
|
"You cannot KILL yourself",
|
||||||
@@ -1476,9 +1460,12 @@ func (c *Conn) handleKillCmd(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
quitReason := "Killed (" + c.nick + " (" + reason + "))"
|
quitReason := "Killed (" + killerNick +
|
||||||
|
" (" + reason + "))"
|
||||||
|
|
||||||
c.svc.BroadcastQuit(
|
// KillSession broadcasts the QUIT, deletes the session
|
||||||
|
// and disconnects the victim's wire connection.
|
||||||
|
c.svc.KillSession(
|
||||||
ctx, targetSID, targetNick, quitReason,
|
ctx, targetSID, targetNick, quitReason,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1530,7 +1517,7 @@ func (c *Conn) handleWallopsCmd(
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, _, _ = c.svc.FanOut(
|
_, _, _ = c.svc.FanOut(
|
||||||
ctx, irc.CmdWallops, c.nick, "*",
|
ctx, irc.CmdWallops, c.currentNick(), "*",
|
||||||
nil, body, nil, wallopsSIDs,
|
nil, body, nil, wallopsSIDs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ type Conn struct {
|
|||||||
serverSfx string
|
serverSfx string
|
||||||
commands map[string]cmdHandler
|
commands map[string]cmdHandler
|
||||||
|
|
||||||
|
// writeMu serializes writes to conn. A connection is
|
||||||
|
// written to by its own read loop, by its relay
|
||||||
|
// goroutine, and — when an operator KILLs it — by
|
||||||
|
// another client's goroutine.
|
||||||
|
writeMu sync.Mutex
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
nick string
|
nick string
|
||||||
username string
|
username string
|
||||||
@@ -62,6 +68,7 @@ type Conn struct {
|
|||||||
|
|
||||||
lastQueueID int64
|
lastQueueID int64
|
||||||
closed bool
|
closed bool
|
||||||
|
killed bool
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +105,47 @@ func newConn(
|
|||||||
return conn
|
return conn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disconnect terminates the connection on behalf of an
|
||||||
|
// operator KILL issued from either transport. The victim
|
||||||
|
// is told why, then its socket is closed so that the read
|
||||||
|
// loop unblocks and serve() returns; without the close the
|
||||||
|
// victim would keep a socket that looks alive but silently
|
||||||
|
// delivers nothing. Disconnect is called from the killer's
|
||||||
|
// goroutine, never the victim's.
|
||||||
|
func (c *Conn) Disconnect(reason string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
|
||||||
|
if c.closed {
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.closed = true
|
||||||
|
c.killed = true
|
||||||
|
nick := c.nick
|
||||||
|
host := c.hostname
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if nick == "" {
|
||||||
|
nick = "*"
|
||||||
|
}
|
||||||
|
|
||||||
|
c.sendFromServer(irc.CmdKill, nick, reason)
|
||||||
|
c.send(
|
||||||
|
"ERROR :Closing Link: " + host +
|
||||||
|
" (" + reason + ")",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stop the relay goroutine, which would otherwise keep
|
||||||
|
// polling a queue belonging to a deleted session.
|
||||||
|
if c.cancel != nil {
|
||||||
|
c.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.conn.Close() //nolint:errcheck,gosec
|
||||||
|
}
|
||||||
|
|
||||||
// buildCommandMap returns a map from IRC command strings
|
// buildCommandMap returns a map from IRC command strings
|
||||||
// to handler functions.
|
// to handler functions.
|
||||||
func (c *Conn) buildCommandMap() map[string]cmdHandler {
|
func (c *Conn) buildCommandMap() map[string]cmdHandler {
|
||||||
@@ -131,10 +179,10 @@ func (c *Conn) buildCommandMap() map[string]cmdHandler {
|
|||||||
c.handleCAP(msg)
|
c.handleCAP(msg)
|
||||||
},
|
},
|
||||||
"USERHOST": c.handleUserhost,
|
"USERHOST": c.handleUserhost,
|
||||||
irc.CmdVersion: func(ctx context.Context, _ *Message) { c.handleVersion(ctx) },
|
irc.CmdVersion: func(context.Context, *Message) { c.handleVersion() },
|
||||||
irc.CmdAdmin: func(ctx context.Context, _ *Message) { c.handleAdmin(ctx) },
|
irc.CmdAdmin: func(context.Context, *Message) { c.handleAdmin() },
|
||||||
irc.CmdInfo: func(ctx context.Context, _ *Message) { c.handleInfo(ctx) },
|
irc.CmdInfo: func(context.Context, *Message) { c.handleInfo() },
|
||||||
irc.CmdTime: func(ctx context.Context, _ *Message) { c.handleTime(ctx) },
|
irc.CmdTime: func(context.Context, *Message) { c.handleTime() },
|
||||||
irc.CmdKill: c.handleKillCmd,
|
irc.CmdKill: c.handleKillCmd,
|
||||||
irc.CmdWallops: c.handleWallopsCmd,
|
irc.CmdWallops: c.handleWallopsCmd,
|
||||||
}
|
}
|
||||||
@@ -185,7 +233,7 @@ func (c *Conn) serve(ctx context.Context) {
|
|||||||
|
|
||||||
c.handleMessage(ctx, msg)
|
c.handleMessage(ctx, msg)
|
||||||
|
|
||||||
if c.closed {
|
if c.isClosed() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,22 +242,52 @@ func (c *Conn) serve(ctx context.Context) {
|
|||||||
func (c *Conn) cleanup(ctx context.Context) {
|
func (c *Conn) cleanup(ctx context.Context) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
wasRegistered := c.registered
|
wasRegistered := c.registered
|
||||||
|
wasKilled := c.killed
|
||||||
sessID := c.sessionID
|
sessID := c.sessionID
|
||||||
nick := c.nick
|
nick := c.nick
|
||||||
c.closed = true
|
c.closed = true
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
if wasRegistered && sessID > 0 {
|
if wasRegistered && sessID > 0 {
|
||||||
|
c.svc.UnregisterWireConn(sessID, c)
|
||||||
|
|
||||||
|
// A KILLed session has already been broadcast and
|
||||||
|
// deleted by the killer; broadcasting again would
|
||||||
|
// fan out a QUIT for a session row that is gone.
|
||||||
|
if !wasKilled {
|
||||||
c.svc.BroadcastQuit(
|
c.svc.BroadcastQuit(
|
||||||
ctx, sessID, nick, "Connection closed",
|
ctx, sessID, nick, "Connection closed",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.conn.Close() //nolint:errcheck,gosec
|
c.conn.Close() //nolint:errcheck,gosec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isClosed reports whether the connection has been marked
|
||||||
|
// for teardown, either by QUIT or by an operator KILL.
|
||||||
|
func (c *Conn) isClosed() bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
return c.closed
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentNick returns the connection's registered nick.
|
||||||
|
// c.nick is written under c.mu during registration and
|
||||||
|
// NICK changes, so every read must take the mutex.
|
||||||
|
func (c *Conn) currentNick() string {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
return c.nick
|
||||||
|
}
|
||||||
|
|
||||||
// send writes a formatted IRC line to the connection.
|
// send writes a formatted IRC line to the connection.
|
||||||
func (c *Conn) send(line string) {
|
func (c *Conn) send(line string) {
|
||||||
|
c.writeMu.Lock()
|
||||||
|
defer c.writeMu.Unlock()
|
||||||
|
|
||||||
_ = c.conn.SetWriteDeadline(
|
_ = c.conn.SetWriteDeadline(
|
||||||
time.Now().Add(writeTimeout),
|
time.Now().Add(writeTimeout),
|
||||||
)
|
)
|
||||||
@@ -392,7 +470,10 @@ func (c *Conn) completeRegistration(ctx context.Context) {
|
|||||||
"failed to create session", "error", err,
|
"failed to create session", "error", err,
|
||||||
)
|
)
|
||||||
c.send("ERROR :Internal server error")
|
c.send("ERROR :Internal server error")
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
c.closed = true
|
c.closed = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -403,6 +484,10 @@ func (c *Conn) completeRegistration(ctx context.Context) {
|
|||||||
c.registered = true
|
c.registered = true
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
// Make this connection reachable by session ID so that
|
||||||
|
// KILL from either transport can disconnect it.
|
||||||
|
c.svc.RegisterWireConn(sessionID, c)
|
||||||
|
|
||||||
// If PASS was provided before registration, set the
|
// If PASS was provided before registration, set the
|
||||||
// session password.
|
// session password.
|
||||||
if c.passWord != "" && len(c.passWord) >= minPasswordLen {
|
if c.passWord != "" && len(c.passWord) >= minPasswordLen {
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/neoirc/internal/broker"
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
"sneak.berlin/go/neoirc/internal/config"
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
"sneak.berlin/go/neoirc/internal/db"
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
"sneak.berlin/go/neoirc/internal/service"
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,8 +21,14 @@ func NewTestServer(
|
|||||||
database *db.Database,
|
database *db.Database,
|
||||||
brk *broker.Broker,
|
brk *broker.Broker,
|
||||||
) *Server {
|
) *Server {
|
||||||
|
globs := &globals.Globals{
|
||||||
|
Appname: "neoirc",
|
||||||
|
Version: "test",
|
||||||
|
StartTime: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
svc := service.NewTestService(
|
svc := service.NewTestService(
|
||||||
database, brk, cfg, log,
|
database, brk, cfg, globs, log,
|
||||||
)
|
)
|
||||||
|
|
||||||
return &Server{ //nolint:exhaustruct
|
return &Server{ //nolint:exhaustruct
|
||||||
|
|||||||
@@ -923,7 +923,11 @@ func TestIntegrationTime(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestIntegrationKill verifies the KILL command: oper can
|
// TestIntegrationKill verifies the KILL command: oper can
|
||||||
// kill a user, non-oper cannot.
|
// kill a user, non-oper cannot, and — most importantly —
|
||||||
|
// that the victim is actually disconnected rather than
|
||||||
|
// merely announced as having quit.
|
||||||
|
//
|
||||||
|
//nolint:funlen // one KILL scenario asserted end to end
|
||||||
func TestIntegrationKill(t *testing.T) {
|
func TestIntegrationKill(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -965,6 +969,24 @@ func TestIntegrationKill(t *testing.T) {
|
|||||||
// Oper KILL should succeed.
|
// Oper KILL should succeed.
|
||||||
alice.send("KILL bob :bad behavior")
|
alice.send("KILL bob :bad behavior")
|
||||||
|
|
||||||
|
// The victim must be told why and then disconnected.
|
||||||
|
// Reading to EOF is the assertion that matters: a KILL
|
||||||
|
// that only broadcasts a QUIT leaves bob holding a
|
||||||
|
// socket that looks alive but delivers nothing.
|
||||||
|
bobLines := bob.readUntilClosed()
|
||||||
|
assertContains(
|
||||||
|
t, bobLines, "KILL",
|
||||||
|
"victim receives KILL before disconnect",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, bobLines, "ERROR :Closing Link",
|
||||||
|
"victim receives ERROR before disconnect",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, bobLines, "bad behavior",
|
||||||
|
"KILL reason delivered to victim",
|
||||||
|
)
|
||||||
|
|
||||||
// alice should see bob's QUIT relay.
|
// alice should see bob's QUIT relay.
|
||||||
aliceSeesQuit := alice.readUntil(func(l string) bool {
|
aliceSeesQuit := alice.readUntil(func(l string) bool {
|
||||||
return strings.Contains(l, "QUIT") &&
|
return strings.Contains(l, "QUIT") &&
|
||||||
@@ -975,6 +997,32 @@ func TestIntegrationKill(t *testing.T) {
|
|||||||
"KILL reason in QUIT message",
|
"KILL reason in QUIT message",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// bob must be gone from the channel member list.
|
||||||
|
alice.send("NAMES #killtest")
|
||||||
|
|
||||||
|
aliceNames := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 366 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceNames, "alice",
|
||||||
|
"alice still in NAMES after killing bob",
|
||||||
|
)
|
||||||
|
assertNotContains(
|
||||||
|
t, aliceNames, "bob",
|
||||||
|
"killed user must not appear in NAMES",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ...nor from WHO.
|
||||||
|
alice.send("WHO #killtest")
|
||||||
|
|
||||||
|
aliceWho := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 315 ")
|
||||||
|
})
|
||||||
|
assertNotContains(
|
||||||
|
t, aliceWho, "bob",
|
||||||
|
"killed user must not appear in WHO",
|
||||||
|
)
|
||||||
|
|
||||||
// KILL nonexistent nick.
|
// KILL nonexistent nick.
|
||||||
alice.send("KILL nobody123 :gone")
|
alice.send("KILL nobody123 :gone")
|
||||||
|
|
||||||
|
|||||||
@@ -291,6 +291,36 @@ func (tc *testClient) register(nick string) []string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readUntilClosed reads until the peer closes the
|
||||||
|
// connection, returning the lines received first. It
|
||||||
|
// fails the test if the connection is still open when the
|
||||||
|
// read deadline expires, which is what a KILL that never
|
||||||
|
// terminates the victim's socket looks like.
|
||||||
|
func (tc *testClient) readUntilClosed() []string {
|
||||||
|
tc.t.Helper()
|
||||||
|
|
||||||
|
_ = tc.conn.SetReadDeadline(
|
||||||
|
time.Now().Add(testTimeout),
|
||||||
|
)
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
|
||||||
|
for tc.scanner.Scan() {
|
||||||
|
lines = append(lines, tc.scanner.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
err := tc.scanner.Err()
|
||||||
|
if err != nil {
|
||||||
|
tc.t.Fatalf(
|
||||||
|
"expected EOF on victim socket, got %v "+
|
||||||
|
"(lines: %v)",
|
||||||
|
err, lines,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
// assertContains checks that at least one line matches the
|
// assertContains checks that at least one line matches the
|
||||||
// given substring.
|
// given substring.
|
||||||
func assertContains(
|
func assertContains(
|
||||||
@@ -309,6 +339,27 @@ func assertContains(
|
|||||||
t.Errorf("did not find %q in output: %s", substr, description)
|
t.Errorf("did not find %q in output: %s", substr, description)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// assertNotContains checks that no line matches the given
|
||||||
|
// substring.
|
||||||
|
func assertNotContains(
|
||||||
|
t *testing.T,
|
||||||
|
lines []string,
|
||||||
|
substr, description string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.Contains(line, substr) {
|
||||||
|
t.Errorf(
|
||||||
|
"unexpectedly found %q in output: %s",
|
||||||
|
substr, description,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// joinAndDrain joins a channel and reads until
|
// joinAndDrain joins a channel and reads until
|
||||||
// RPL_ENDOFNAMES.
|
// RPL_ENDOFNAMES.
|
||||||
func (tc *testClient) joinAndDrain(channel string) {
|
func (tc *testClient) joinAndDrain(channel string) {
|
||||||
|
|||||||
@@ -92,19 +92,6 @@ func New(
|
|||||||
return srv, nil
|
return srv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run configures the server and begins serving. It blocks
|
|
||||||
// until shutdown is signalled. Kept for external callers
|
|
||||||
// that embed the server outside fx. The fx lifecycle now
|
|
||||||
// performs setup synchronously in OnStart and invokes
|
|
||||||
// serve directly in a goroutine, so this is only used when
|
|
||||||
// the server is driven by hand.
|
|
||||||
func (srv *Server) Run() {
|
|
||||||
srv.configure()
|
|
||||||
srv.enableSentry()
|
|
||||||
srv.SetupRoutes()
|
|
||||||
srv.serve()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeHTTP delegates to the chi router.
|
// ServeHTTP delegates to the chi router.
|
||||||
func (srv *Server) ServeHTTP(
|
func (srv *Server) ServeHTTP(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
|
|||||||
+137
-8
@@ -10,11 +10,14 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/neoirc/internal/broker"
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
"sneak.berlin/go/neoirc/internal/config"
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
"sneak.berlin/go/neoirc/internal/db"
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
"sneak.berlin/go/neoirc/internal/logger"
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
"sneak.berlin/go/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
@@ -27,6 +30,18 @@ type Params struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
Database *db.Database
|
Database *db.Database
|
||||||
Broker *broker.Broker
|
Broker *broker.Broker
|
||||||
|
Globals *globals.Globals
|
||||||
|
}
|
||||||
|
|
||||||
|
// WireConn is a live client connection that a transport
|
||||||
|
// registers with the service so that commands such as KILL
|
||||||
|
// can reach it. The IRC wire server registers one per
|
||||||
|
// registered connection; the HTTP transport has no
|
||||||
|
// long-lived socket and registers nothing.
|
||||||
|
type WireConn interface {
|
||||||
|
// Disconnect terminates the connection, telling the
|
||||||
|
// client why before closing the socket.
|
||||||
|
Disconnect(reason string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service provides shared business logic for IRC commands.
|
// Service provides shared business logic for IRC commands.
|
||||||
@@ -34,16 +49,22 @@ type Service struct {
|
|||||||
db *db.Database
|
db *db.Database
|
||||||
broker *broker.Broker
|
broker *broker.Broker
|
||||||
config *config.Config
|
config *config.Config
|
||||||
|
globals *globals.Globals
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
|
||||||
|
wireMu sync.Mutex
|
||||||
|
wireConns map[int64]WireConn
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Service.
|
// New creates a new Service.
|
||||||
func New(params Params) *Service {
|
func New(params Params) *Service {
|
||||||
return &Service{
|
return &Service{ //nolint:exhaustruct // mutex zero value
|
||||||
db: params.Database,
|
db: params.Database,
|
||||||
broker: params.Broker,
|
broker: params.Broker,
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
|
globals: params.Globals,
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
|
wireConns: make(map[int64]WireConn),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,13 +74,105 @@ func NewTestService(
|
|||||||
database *db.Database,
|
database *db.Database,
|
||||||
brk *broker.Broker,
|
brk *broker.Broker,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
|
globs *globals.Globals,
|
||||||
log *slog.Logger,
|
log *slog.Logger,
|
||||||
) *Service {
|
) *Service {
|
||||||
return &Service{
|
return &Service{ //nolint:exhaustruct // mutex zero value
|
||||||
db: database,
|
db: database,
|
||||||
broker: brk,
|
broker: brk,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
globals: globs,
|
||||||
log: log,
|
log: log,
|
||||||
|
wireConns: make(map[int64]WireConn),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerVersion returns the canonical server version string
|
||||||
|
// used by every transport, e.g. "neoirc-1.2.3". Both the
|
||||||
|
// IRC wire protocol and the HTTP API must report the same
|
||||||
|
// string, so this is the only place it is built.
|
||||||
|
func (s *Service) ServerVersion() string {
|
||||||
|
name := "neoirc"
|
||||||
|
ver := "dev"
|
||||||
|
|
||||||
|
if s.globals != nil {
|
||||||
|
if s.globals.Appname != "" {
|
||||||
|
name = s.globals.Appname
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.globals.Version != "" {
|
||||||
|
ver = s.globals.Version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return name + "-" + ver
|
||||||
|
}
|
||||||
|
|
||||||
|
// InfoLines returns the RPL_INFO body. Both transports
|
||||||
|
// send exactly these lines so that INFO does not diverge
|
||||||
|
// between the wire protocol and the HTTP API.
|
||||||
|
func (s *Service) InfoLines() []string {
|
||||||
|
started := "unknown"
|
||||||
|
if s.globals != nil && !s.globals.StartTime.IsZero() {
|
||||||
|
started = s.globals.StartTime.Format(time.RFC1123)
|
||||||
|
}
|
||||||
|
|
||||||
|
return []string{
|
||||||
|
"neoirc — IRC semantics over HTTP",
|
||||||
|
"Version: " + s.ServerVersion(),
|
||||||
|
"Written in Go",
|
||||||
|
"Started: " + started,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterWireConn associates a live wire connection with
|
||||||
|
// its session ID so that KillSession can reach it.
|
||||||
|
func (s *Service) RegisterWireConn(
|
||||||
|
sessionID int64,
|
||||||
|
conn WireConn,
|
||||||
|
) {
|
||||||
|
s.wireMu.Lock()
|
||||||
|
defer s.wireMu.Unlock()
|
||||||
|
|
||||||
|
s.wireConns[sessionID] = conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnregisterWireConn removes the association created by
|
||||||
|
// RegisterWireConn. It is a no-op if the session has
|
||||||
|
// already been rebound to a different connection.
|
||||||
|
func (s *Service) UnregisterWireConn(
|
||||||
|
sessionID int64,
|
||||||
|
conn WireConn,
|
||||||
|
) {
|
||||||
|
s.wireMu.Lock()
|
||||||
|
defer s.wireMu.Unlock()
|
||||||
|
|
||||||
|
if s.wireConns[sessionID] == conn {
|
||||||
|
delete(s.wireConns, sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// KillSession forcibly removes a user from the server: the
|
||||||
|
// victim's channel peers are told via QUIT, the victim's
|
||||||
|
// session is deleted, and any live wire connection it holds
|
||||||
|
// is disconnected. Both the IRC KILL command and the HTTP
|
||||||
|
// KILL endpoint route through here so the two transports
|
||||||
|
// cannot diverge.
|
||||||
|
func (s *Service) KillSession(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
nick, reason string,
|
||||||
|
) {
|
||||||
|
s.BroadcastQuit(ctx, sessionID, nick, reason)
|
||||||
|
|
||||||
|
// A session with no registered wire connection (an
|
||||||
|
// HTTP-only client) has nothing left to disconnect.
|
||||||
|
s.wireMu.Lock()
|
||||||
|
conn := s.wireConns[sessionID]
|
||||||
|
s.wireMu.Unlock()
|
||||||
|
|
||||||
|
if conn != nil {
|
||||||
|
conn.Disconnect(reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -792,26 +905,42 @@ func (s *Service) QueryChannelMode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// QueryUserMode returns the current user mode string for
|
// QueryUserMode returns the current user mode string for
|
||||||
// the given session (e.g. "+ow", "+w", "+").
|
// the given session (e.g. "+ow", "+w", "+"). A database
|
||||||
|
// failure is returned rather than being reported as an
|
||||||
|
// unset flag: an unreadable mode is not the same as an
|
||||||
|
// absent one, and reporting "+" for either would tell the
|
||||||
|
// user they are de-opered when the truth is unknown.
|
||||||
func (s *Service) QueryUserMode(
|
func (s *Service) QueryUserMode(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID int64,
|
sessionID int64,
|
||||||
) string {
|
) (string, error) {
|
||||||
modes := "+"
|
modes := "+"
|
||||||
|
|
||||||
isOper, err := s.db.IsSessionOper(ctx, sessionID)
|
isOper, err := s.db.IsSessionOper(ctx, sessionID)
|
||||||
if err == nil && isOper {
|
if err != nil {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"query oper flag: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isOper {
|
||||||
modes += "o"
|
modes += "o"
|
||||||
}
|
}
|
||||||
|
|
||||||
isWallops, err := s.db.IsSessionWallops(
|
isWallops, err := s.db.IsSessionWallops(
|
||||||
ctx, sessionID,
|
ctx, sessionID,
|
||||||
)
|
)
|
||||||
if err == nil && isWallops {
|
if err != nil {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"query wallops flag: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isWallops {
|
||||||
modes += "w"
|
modes += "w"
|
||||||
}
|
}
|
||||||
|
|
||||||
return modes
|
return modes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// userModeOp is a single parsed user-mode change collected
|
// userModeOp is a single parsed user-mode change collected
|
||||||
@@ -847,7 +976,7 @@ func (s *Service) ApplyUserMode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.QueryUserMode(ctx, sessionID), nil
|
return s.QueryUserMode(ctx, sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseUserModeString validates and parses a user-mode
|
// parseUserModeString validates and parses a user-mode
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"go.uber.org/fx/fxtest"
|
"go.uber.org/fx/fxtest"
|
||||||
@@ -55,9 +56,10 @@ func newTestEnv(t *testing.T) *testEnv {
|
|||||||
app := fxtest.New(t,
|
app := fxtest.New(t,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
func() *globals.Globals {
|
func() *globals.Globals {
|
||||||
return &globals.Globals{ //nolint:exhaustruct
|
return &globals.Globals{
|
||||||
Appname: "neoirc-test",
|
Appname: "neoirc-test",
|
||||||
Version: "test",
|
Version: "test",
|
||||||
|
StartTime: time.Now(),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -371,7 +373,11 @@ func TestQueryUserMode(t *testing.T) {
|
|||||||
sid := createSession(ctx, t, env.db, "alice")
|
sid := createSession(ctx, t, env.db, "alice")
|
||||||
|
|
||||||
// Fresh session has no modes.
|
// Fresh session has no modes.
|
||||||
modes := env.svc.QueryUserMode(ctx, sid)
|
modes, err := env.svc.QueryUserMode(ctx, sid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query user mode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if modes != "+" {
|
if modes != "+" {
|
||||||
t.Errorf("expected +, got %s", modes)
|
t.Errorf("expected +, got %s", modes)
|
||||||
}
|
}
|
||||||
@@ -379,7 +385,11 @@ func TestQueryUserMode(t *testing.T) {
|
|||||||
// Set wallops.
|
// Set wallops.
|
||||||
_ = env.db.SetSessionWallops(ctx, sid, true)
|
_ = env.db.SetSessionWallops(ctx, sid, true)
|
||||||
|
|
||||||
modes = env.svc.QueryUserMode(ctx, sid)
|
modes, err = env.svc.QueryUserMode(ctx, sid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query user mode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if modes != "+w" {
|
if modes != "+w" {
|
||||||
t.Errorf("expected +w, got %s", modes)
|
t.Errorf("expected +w, got %s", modes)
|
||||||
}
|
}
|
||||||
@@ -387,7 +397,11 @@ func TestQueryUserMode(t *testing.T) {
|
|||||||
// Set oper.
|
// Set oper.
|
||||||
_ = env.db.SetSessionOper(ctx, sid, true)
|
_ = env.db.SetSessionOper(ctx, sid, true)
|
||||||
|
|
||||||
modes = env.svc.QueryUserMode(ctx, sid)
|
modes, err = env.svc.QueryUserMode(ctx, sid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query user mode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if modes != "+ow" {
|
if modes != "+ow" {
|
||||||
t.Errorf("expected +ow, got %s", modes)
|
t.Errorf("expected +ow, got %s", modes)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user