Add the tier 3 utility IRC commands (closes #87)
check / check (push) Waiting to run

USERHOST, VERSION, ADMIN, INFO, TIME, KILL and WALLOPS on both the
HTTP API and the IRC listener, and user mode +w.

Model: opus-5-5
This commit is contained in:
2026-10-08 00:26:08 +00:00
parent b119b094f5
commit e28fa005ed
19 changed files with 3931 additions and 85 deletions
+322 -14
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"
)
@@ -33,23 +36,41 @@ 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.
type Service struct {
db *db.Database
broker *broker.Broker
config *config.Config
log *slog.Logger
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{
db: params.Database,
broker: params.Broker,
config: params.Config,
log: params.Logger.Get(),
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),
}
}
@@ -59,13 +80,105 @@ func NewTestService(
database *db.Database,
brk *broker.Broker,
cfg *config.Config,
globs *globals.Globals,
log *slog.Logger,
) *Service {
return &Service{
db: database,
broker: brk,
config: cfg,
log: log,
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,6 +905,201 @@ func (s *Service) QueryChannelMode(
return modes + modeParams
}
// QueryUserMode returns the current user mode string for
// 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, error) {
modes := "+"
isOper, err := s.db.IsSessionOper(ctx, sessionID)
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 {
return "", fmt.Errorf(
"query wallops flag: %w", err,
)
}
if isWallops {
modes += "w"
}
return modes, nil
}
// userModeOp is a single parsed user-mode change collected
// by parseUserModeString before any DB writes happen.
type userModeOp struct {
char rune
adding bool
}
// ApplyUserMode parses an IRC user-mode string and applies
// the resulting changes atomically. It supports multiple
// sign transitions (e.g. "+w-o", "-w+o", "+o-w+w") and
// rejects malformed input (empty string, no leading sign,
// bare sign with no mode letters, unknown mode letters,
// +o which must be set via OPER) with an IRCError. On
// failure, no persistent change is made: parsing happens
// before any write, and the writes themselves run inside a
// single database transaction that is rolled back whole if
// any statement fails. On success, the resulting mode
// string is returned.
func (s *Service) ApplyUserMode(
ctx context.Context,
sessionID int64,
modeStr string,
) (string, error) {
ops, err := parseUserModeString(modeStr)
if err != nil {
return "", err
}
wallops, oper, err := collapseUserModeOps(ops)
if err != nil {
return "", err
}
if err := s.db.SetSessionUserModes(
ctx, sessionID, wallops, oper,
); err != nil {
s.log.Error(
"apply user modes failed", "error", err,
)
return "", fmt.Errorf("apply user modes: %w", err)
}
return s.QueryUserMode(ctx, sessionID)
}
// parseUserModeString validates and parses a user-mode
// string into a list of operations. The string must begin
// with '+' or '-'; subsequent '+' / '-' characters flip the
// active sign, and letters between them are applied with
// the current sign. Every letter must be a recognized user
// mode for this server, and '+o' is never allowed via MODE
// (use OPER to become operator). If any character is
// invalid, no operations are returned and an IRCError with
// ERR_UMODEUNKNOWNFLAG (501) is returned.
func parseUserModeString(
modeStr string,
) ([]userModeOp, error) {
unknownFlag := &IRCError{
Code: irc.ErrUmodeUnknownFlag,
Params: nil,
Message: "Unknown MODE flag",
}
if modeStr == "" {
return nil, unknownFlag
}
first := modeStr[0]
if first != '+' && first != '-' {
return nil, unknownFlag
}
ops := make([]userModeOp, 0, len(modeStr)-1)
adding := true
for _, modeChar := range modeStr {
switch modeChar {
case '+':
adding = true
case '-':
adding = false
default:
if !isKnownUserModeChar(modeChar) {
return nil, unknownFlag
}
if modeChar == 'o' && adding {
return nil, unknownFlag
}
ops = append(ops, userModeOp{
char: modeChar, adding: adding,
})
}
}
if len(ops) == 0 {
return nil, unknownFlag
}
return ops, nil
}
// isKnownUserModeChar reports whether the character is a
// recognized user mode letter.
func isKnownUserModeChar(modeChar rune) bool {
switch modeChar {
case 'w', 'o':
return true
default:
return false
}
}
// collapseUserModeOps reduces an already-parsed operation
// list to the final desired value of each user mode flag.
// A nil result for a flag means the mode string never
// mentioned it, so it must be left untouched. Later
// operations win over earlier ones for the same letter
// (e.g. "+w-w" ends with wallops off), which matches the
// left-to-right semantics of applying each op in turn.
// parseUserModeString must have validated every character
// and sign before this runs; the default branch here is
// defence-in-depth only.
func collapseUserModeOps(
ops []userModeOp,
) (*bool, *bool, error) {
unknownFlag := &IRCError{
Code: irc.ErrUmodeUnknownFlag,
Params: nil,
Message: "Unknown MODE flag",
}
var wallops, oper *bool
for _, modeOp := range ops {
switch modeOp.char {
case 'w':
val := modeOp.adding
wallops = &val
case 'o':
if modeOp.adding {
return nil, nil, unknownFlag
}
val := false
oper = &val
default:
return nil, nil, unknownFlag
}
}
return wallops, oper, nil
}
// broadcastNickChange notifies channel peers of a nick
// change.
func (s *Service) broadcastNickChange(