feat: implement Tier 3 utility IRC commands (USERHOST, VERSION, ADMIN, INFO, TIME, KILL, WALLOPS) (closes #87) #96

Open
clawbot wants to merge 13 commits from feature/87-tier3-utility-commands into next
13 changed files with 600 additions and 158 deletions
Showing only changes of commit c20ad88dfe - Show all commits
+9
View File
@@ -2324,6 +2324,15 @@ IRC_LISTEN_ADDR=
operator status (`@`).
- **Channel modes**: `+m` (moderated), `+t` (topic lock), `+o` (operator),
`+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
+11 -2
View File
@@ -7,6 +7,7 @@ import (
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
@@ -2509,7 +2510,9 @@ type UserhostInfo struct {
}
// 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(
ctx context.Context,
nicks []string,
@@ -2534,7 +2537,13 @@ func (database *Database) GetUserhostInfo(
&info.IsOper, &info.AwayMessage,
)
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)
+1
View File
@@ -214,6 +214,7 @@ func newTestHandlers(
Config: cfg,
Database: database,
Broker: brk,
Globals: globs,
})
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
+62 -37
View File
@@ -184,7 +184,7 @@ func (hdlr *Handlers) handleVersion(
) {
ctx := request.Context()
srvName := hdlr.serverName()
version := hdlr.serverVersion()
version := hdlr.svc.ServerVersion()
// 351 RPL_VERSION
hdlr.enqueueNumeric(
@@ -250,18 +250,8 @@ func (hdlr *Handlers) handleInfo(
nick string,
) {
ctx := request.Context()
version := hdlr.serverVersion()
infoLines := []string{
"neoirc — IRC semantics over HTTP",
"Version: " + version,
"Written in Go",
"Started: " +
hdlr.params.Globals.StartTime.
Format(time.RFC1123),
}
for _, line := range infoLines {
for _, line := range hdlr.svc.InfoLines() {
// 371 RPL_INFO
hdlr.enqueueNumeric(
ctx, clientID, irc.RplInfo, nick, nil,
@@ -379,9 +369,11 @@ func (hdlr *Handlers) handleKill(
quitReason := "Killed (" + nick + " (" + reason + "))"
hdlr.svc.BroadcastQuit(
request.Context(), targetSID,
targetNick, quitReason,
// 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,
@@ -484,12 +476,12 @@ func (hdlr *Handlers) handleUserMode(
) {
ctx := request.Context()
lines := bodyLines()
// Mode change requested.
if len(lines) > 0 {
// Users can only change their own modes.
if target != nick && target != "" {
// 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,
@@ -499,8 +491,56 @@ func (hdlr *Handlers) handleUserMode(
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, lines[0],
ctx, sessionID, modeStr,
)
if err != nil {
var ircErr *service.IRCError
@@ -533,19 +573,4 @@ func (hdlr *Handlers) handleUserMode(
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
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)
}
+89
View File
@@ -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
// message. The body is stored as a JSON array; this
// returns the first element.
+34 -47
View File
@@ -8,29 +8,10 @@ import (
"strings"
"time"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/service"
"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
// reply on the wire.
func (c *Conn) sendIRCError(err error) {
@@ -368,7 +349,10 @@ func (c *Conn) handleQuit(msg *Message) {
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
@@ -719,7 +703,7 @@ func (c *Conn) handleUserMode(
) {
target := msg.Params[0]
if !strings.EqualFold(target, c.nick) {
if !strings.EqualFold(target, c.currentNick()) {
c.sendNumeric(
irc.ErrUsersDoNotMatch,
"Can't change mode for other users",
@@ -730,7 +714,21 @@ func (c *Conn) handleUserMode(
// Mode query (no mode string).
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)
return
@@ -1349,22 +1347,16 @@ func (c *Conn) handleUserhost(
}
// handleVersion replies with the server version string.
func (c *Conn) handleVersion(ctx context.Context) {
_ = ctx
version := versionString()
func (c *Conn) handleVersion() {
c.sendNumeric(
irc.RplVersion,
version+".", c.cfg.ServerName,
c.svc.ServerVersion()+".", c.cfg.ServerName,
"",
)
}
// handleAdmin replies with server admin info.
func (c *Conn) handleAdmin(ctx context.Context) {
_ = ctx
func (c *Conn) handleAdmin() {
srvName := c.cfg.ServerName
c.sendNumeric(
@@ -1389,16 +1381,8 @@ func (c *Conn) handleAdmin(ctx context.Context) {
}
// handleInfo replies with server software info.
func (c *Conn) handleInfo(ctx context.Context) {
_ = ctx
infoLines := []string{
"neoirc — IRC semantics over HTTP",
"Version: " + versionString(),
"Written in Go",
}
for _, line := range infoLines {
func (c *Conn) handleInfo() {
for _, line := range c.svc.InfoLines() {
c.sendNumeric(irc.RplInfo, line)
}
@@ -1409,9 +1393,7 @@ func (c *Conn) handleInfo(ctx context.Context) {
}
// handleTime replies with the server's current time.
func (c *Conn) handleTime(ctx context.Context) {
_ = ctx
func (c *Conn) handleTime() {
srvName := c.cfg.ServerName
c.sendNumeric(
@@ -1455,7 +1437,9 @@ func (c *Conn) handleKillCmd(
reason = msg.Params[1]
}
if targetNick == c.nick {
killerNick := c.currentNick()
if strings.EqualFold(targetNick, killerNick) {
c.sendNumeric(
irc.ErrCantKillServer,
"You cannot KILL yourself",
@@ -1476,9 +1460,12 @@ func (c *Conn) handleKillCmd(
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,
)
}
@@ -1530,7 +1517,7 @@ func (c *Conn) handleWallopsCmd(
}
_, _, _ = c.svc.FanOut(
ctx, irc.CmdWallops, c.nick, "*",
ctx, irc.CmdWallops, c.currentNick(), "*",
nil, body, nil, wallopsSIDs,
)
}
+90 -5
View File
@@ -46,6 +46,12 @@ type Conn struct {
serverSfx string
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
nick string
username string
@@ -62,6 +68,7 @@ type Conn struct {
lastQueueID int64
closed bool
killed bool
cancel context.CancelFunc
}
@@ -98,6 +105,47 @@ func newConn(
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
// to handler functions.
func (c *Conn) buildCommandMap() map[string]cmdHandler {
@@ -131,10 +179,10 @@ func (c *Conn) buildCommandMap() map[string]cmdHandler {
c.handleCAP(msg)
},
"USERHOST": c.handleUserhost,
irc.CmdVersion: func(ctx context.Context, _ *Message) { c.handleVersion(ctx) },
irc.CmdAdmin: func(ctx context.Context, _ *Message) { c.handleAdmin(ctx) },
irc.CmdInfo: func(ctx context.Context, _ *Message) { c.handleInfo(ctx) },
irc.CmdTime: func(ctx context.Context, _ *Message) { c.handleTime(ctx) },
irc.CmdVersion: func(context.Context, *Message) { c.handleVersion() },
irc.CmdAdmin: func(context.Context, *Message) { c.handleAdmin() },
irc.CmdInfo: func(context.Context, *Message) { c.handleInfo() },
irc.CmdTime: func(context.Context, *Message) { c.handleTime() },
irc.CmdKill: c.handleKillCmd,
irc.CmdWallops: c.handleWallopsCmd,
}
@@ -185,7 +233,7 @@ func (c *Conn) serve(ctx context.Context) {
c.handleMessage(ctx, msg)
if c.closed {
if c.isClosed() {
return
}
}
@@ -194,22 +242,52 @@ func (c *Conn) serve(ctx context.Context) {
func (c *Conn) cleanup(ctx context.Context) {
c.mu.Lock()
wasRegistered := c.registered
wasKilled := c.killed
sessID := c.sessionID
nick := c.nick
c.closed = true
c.mu.Unlock()
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(
ctx, sessID, nick, "Connection closed",
)
}
}
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.
func (c *Conn) send(line string) {
c.writeMu.Lock()
defer c.writeMu.Unlock()
_ = c.conn.SetWriteDeadline(
time.Now().Add(writeTimeout),
)
@@ -392,7 +470,10 @@ func (c *Conn) completeRegistration(ctx context.Context) {
"failed to create session", "error", err,
)
c.send("ERROR :Internal server error")
c.mu.Lock()
c.closed = true
c.mu.Unlock()
return
}
@@ -403,6 +484,10 @@ func (c *Conn) completeRegistration(ctx context.Context) {
c.registered = true
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
// session password.
if c.passWord != "" && len(c.passWord) >= minPasswordLen {
+9 -1
View File
@@ -4,10 +4,12 @@ import (
"context"
"log/slog"
"net"
"time"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/service"
)
@@ -19,8 +21,14 @@ func NewTestServer(
database *db.Database,
brk *broker.Broker,
) *Server {
globs := &globals.Globals{
Appname: "neoirc",
Version: "test",
StartTime: time.Now(),
}
svc := service.NewTestService(
database, brk, cfg, log,
database, brk, cfg, globs, log,
)
return &Server{ //nolint:exhaustruct
+49 -1
View File
@@ -923,7 +923,11 @@ func TestIntegrationTime(t *testing.T) {
}
// 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) {
t.Parallel()
@@ -965,6 +969,24 @@ func TestIntegrationKill(t *testing.T) {
// Oper KILL should succeed.
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.
aliceSeesQuit := alice.readUntil(func(l string) bool {
return strings.Contains(l, "QUIT") &&
@@ -975,6 +997,32 @@ func TestIntegrationKill(t *testing.T) {
"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.
alice.send("KILL nobody123 :gone")
+51
View File
@@ -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
// given substring.
func assertContains(
@@ -309,6 +339,27 @@ func assertContains(
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
// RPL_ENDOFNAMES.
func (tc *testClient) joinAndDrain(channel string) {
-13
View File
@@ -92,19 +92,6 @@ func New(
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.
func (srv *Server) ServeHTTP(
writer http.ResponseWriter,
+137 -8
View File
@@ -10,11 +10,14 @@ import (
"log/slog"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/pkg/irc"
)
@@ -27,6 +30,18 @@ type Params struct {
Config *config.Config
Database *db.Database
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.
@@ -34,16 +49,22 @@ type Service struct {
db *db.Database
broker *broker.Broker
config *config.Config
globals *globals.Globals
log *slog.Logger
wireMu sync.Mutex
wireConns map[int64]WireConn
}
// New creates a new Service.
func New(params Params) *Service {
return &Service{
return &Service{ //nolint:exhaustruct // mutex zero value
db: params.Database,
broker: params.Broker,
config: params.Config,
globals: params.Globals,
log: params.Logger.Get(),
wireConns: make(map[int64]WireConn),
}
}
@@ -53,13 +74,105 @@ func NewTestService(
database *db.Database,
brk *broker.Broker,
cfg *config.Config,
globs *globals.Globals,
log *slog.Logger,
) *Service {
return &Service{
return &Service{ //nolint:exhaustruct // mutex zero value
db: database,
broker: brk,
config: cfg,
globals: globs,
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
// 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(
ctx context.Context,
sessionID int64,
) string {
) (string, error) {
modes := "+"
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"
}
isWallops, err := s.db.IsSessionWallops(
ctx, sessionID,
)
if err == nil && isWallops {
if err != nil {
return "", fmt.Errorf(
"query wallops flag: %w", err,
)
}
if isWallops {
modes += "w"
}
return modes
return modes, nil
}
// 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
+18 -4
View File
@@ -11,6 +11,7 @@ import (
"fmt"
"os"
"testing"
"time"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
@@ -55,9 +56,10 @@ func newTestEnv(t *testing.T) *testEnv {
app := fxtest.New(t,
fx.Provide(
func() *globals.Globals {
return &globals.Globals{ //nolint:exhaustruct
return &globals.Globals{
Appname: "neoirc-test",
Version: "test",
StartTime: time.Now(),
}
},
logger.New,
@@ -371,7 +373,11 @@ func TestQueryUserMode(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice")
// 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 != "+" {
t.Errorf("expected +, got %s", modes)
}
@@ -379,7 +385,11 @@ func TestQueryUserMode(t *testing.T) {
// Set wallops.
_ = 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" {
t.Errorf("expected +w, got %s", modes)
}
@@ -387,7 +397,11 @@ func TestQueryUserMode(t *testing.T) {
// Set oper.
_ = 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" {
t.Errorf("expected +ow, got %s", modes)
}