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

USERHOST, VERSION, ADMIN, INFO, TIME, KILL and WALLOPS work on the
HTTP API and on the IRC listener. The USERHOST reply, the VERSION,
ADMIN and INFO text, KILL and WALLOPS are each one service function
that both transports call. User mode +w selects who receives WALLOPS.
MODE on your own nick queries or changes your user modes through one
parser on both transports: the whole mode string is checked before
anything is stored, and both flags are stored in one transaction. KILL
closes the victim's IRC connection after sending it KILL and ERROR,
without the operator waiting on it. The sessions table gains
is_wallops in 001_initial.sql, as the pre-1.0 rule in REPO_POLICIES.md
asks.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
This commit was merged in pull request #96.
This commit is contained in:
2026-10-08 07:31:37 +02:00
committed by clawbot
parent b119b094f5
commit 0f2e63d0cc
19 changed files with 2552 additions and 129 deletions
+375 -14
View File
@@ -5,16 +5,21 @@ package service
import (
"context"
"crypto/subtle"
"database/sql"
"encoding/json"
"errors"
"fmt"
"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"
)
@@ -22,9 +27,14 @@ import (
// Error texts that several commands reply with.
const (
msgNoSuchChannel = "No such channel"
msgNoSuchNick = "No such nick/channel"
msgNotChannelOp = "You're not channel operator"
)
// maxUserhostNicks is how many nicks one USERHOST answers
// for (RFC 2812).
const maxUserhostNicks = 5
// Params defines the dependencies for creating a Service.
type Params struct {
fx.In
@@ -33,23 +43,38 @@ type Params struct {
Config *config.Config
Database *db.Database
Broker *broker.Broker
Globals *globals.Globals
}
// WireConn is a registered IRC connection, which KILL closes.
// HTTP clients hold no connection and register none.
type WireConn interface {
// Disconnect tells the client why and closes the
// connection.
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(),
db: params.Database,
broker: params.Broker,
config: params.Config,
globals: params.Globals,
log: params.Logger.Get(),
wireConns: make(map[int64]WireConn),
}
}
@@ -59,16 +84,219 @@ 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,
db: database,
broker: brk,
config: cfg,
globals: globs,
log: log,
wireConns: make(map[int64]WireConn),
}
}
// ServerVersion returns the version that VERSION and INFO
// report on both transports, such as "neoirc-1.2.3".
func (s *Service) ServerVersion() string {
version := s.globals.Version
if version == "" {
version = "dev"
}
return s.globals.Appname + "-" + version
}
// InfoLines returns the lines that INFO sends on both
// transports.
func (s *Service) InfoLines() []string {
return []string{
"neoirc — IRC semantics over HTTP",
"Version: " + s.ServerVersion(),
"Written in Go",
"Started: " + s.globals.StartTime.Format(time.RFC1123),
}
}
// AdminLines returns the texts of the RPL_ADMINLOC1,
// RPL_ADMINLOC2 and RPL_ADMINEMAIL replies, in that order,
// that ADMIN sends on both transports.
func (s *Service) AdminLines(
serverName string,
) (string, string, string) {
return "neoirc server", "IRC over HTTP", "admin@" + serverName
}
// UserhostReply returns the RPL_USERHOST text for the first
// five of the given nicks: "nick=+user@host" entries joined
// by spaces, with * after the nick of an operator and - in
// place of + for a user who is away. Nicks with no session
// are left out. An empty hostname is reported as
// serverName.
func (s *Service) UserhostReply(
ctx context.Context,
nicks []string,
serverName string,
) (string, error) {
if len(nicks) > maxUserhostNicks {
nicks = nicks[:maxUserhostNicks]
}
infos, err := s.db.GetUserhostInfo(ctx, nicks)
if err != nil {
return "", fmt.Errorf("userhost: %w", err)
}
replies := make([]string, 0, len(infos))
for _, info := range infos {
operStar := ""
if info.IsOper {
operStar = "*"
}
away := "+"
if info.AwayMessage != "" {
away = "-"
}
username := info.Username
if username == "" {
username = info.Nick
}
hostname := info.Hostname
if hostname == "" {
hostname = serverName
}
replies = append(replies,
info.Nick+operStar+"="+away+username+"@"+hostname,
)
}
return strings.Join(replies, " "), nil
}
// RegisterWireConn records the IRC connection of a session,
// so that KillUser can close it.
func (s *Service) RegisterWireConn(
sessionID int64,
conn WireConn,
) {
s.wireMu.Lock()
defer s.wireMu.Unlock()
s.wireConns[sessionID] = conn
}
// UnregisterWireConn removes what RegisterWireConn recorded,
// unless the session has since registered another
// 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)
}
}
// KillUser carries out an operator's KILL: the target's
// channel peers see it quit, its session is deleted, and
// its IRC connection, if it has one, is closed. An empty
// reason is given as "KILLed".
func (s *Service) KillUser(
ctx context.Context,
sessionID int64,
nick, targetNick, reason string,
) error {
err := s.requireOper(ctx, sessionID)
if err != nil {
return err
}
targetSID, err := s.db.GetSessionByNick(ctx, targetNick)
if errors.Is(err, sql.ErrNoRows) {
return &IRCError{
irc.ErrNoSuchNick,
[]string{targetNick},
msgNoSuchNick,
}
}
if err != nil {
return fmt.Errorf("kill: %w", err)
}
if targetSID == sessionID {
return &IRCError{
irc.ErrCantKillServer,
nil,
"You cannot KILL yourself",
}
}
if reason == "" {
reason = "KILLed"
}
quitReason := "Killed (" + nick + " (" + reason + "))"
s.BroadcastQuit(ctx, targetSID, targetNick, quitReason)
s.wireMu.Lock()
conn := s.wireConns[targetSID]
s.wireMu.Unlock()
if conn != nil {
conn.Disconnect(quitReason)
}
return nil
}
// SendWallops carries out an operator's WALLOPS: the
// message goes to every user with user mode +w.
func (s *Service) SendWallops(
ctx context.Context,
sessionID int64,
nick, message string,
) error {
err := s.requireOper(ctx, sessionID)
if err != nil {
return err
}
recipients, err := s.db.GetWallopsSessionIDs(ctx)
if err != nil {
return fmt.Errorf("wallops: %w", err)
}
if len(recipients) == 0 {
return nil
}
body, err := json.Marshal([]string{message})
if err != nil {
return fmt.Errorf("wallops: %w", err)
}
_, _, err = s.FanOut(
ctx, irc.CmdWallops, nick, "*",
nil, body, nil, recipients,
)
if err != nil {
return fmt.Errorf("wallops: %w", err)
}
return nil
}
// IRCError represents an IRC protocol-level error with a
// numeric code that both transports can map to responses.
type IRCError struct {
@@ -437,7 +665,7 @@ func (s *Service) KickUser(
return &IRCError{
irc.ErrNoSuchNick,
[]string{targetNick},
"No such nick/channel",
msgNoSuchNick,
}
}
@@ -649,7 +877,7 @@ func (s *Service) ApplyMemberMode(
return &IRCError{
irc.ErrNoSuchNick,
[]string{targetNick},
"No such nick/channel",
msgNoSuchNick,
}
}
@@ -792,6 +1020,139 @@ func (s *Service) QueryChannelMode(
return modes + modeParams
}
// QueryUserMode returns the session's user mode string,
// such as "+", "+w" or "+ow".
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
}
// ApplyUserMode applies a user mode string to the session
// and returns the resulting mode string. The whole string
// is parsed before anything is written, and the flags are
// written in one transaction, so a change that is rejected
// or fails leaves the modes as they were.
func (s *Service) ApplyUserMode(
ctx context.Context,
sessionID int64,
modeStr string,
) (string, error) {
wallops, oper, err := parseUserModeString(modeStr)
if err != nil {
return "", err
}
err = s.db.SetSessionUserModes(
ctx, sessionID, wallops, oper,
)
if err != nil {
return "", fmt.Errorf("apply user modes: %w", err)
}
return s.QueryUserMode(ctx, sessionID)
}
// parseUserModeString parses a user mode string such as
// "+w", "-o" or "+w-o" into the new value of each flag it
// names, or nil for a flag it does not name; a later letter
// overrides an earlier one. The string must start with + or
// - and name at least one mode. w may be set or unset, o
// only unset (OPER sets it). Anything else is rejected with
// ERR_UMODEUNKNOWNFLAG.
func parseUserModeString(
modeStr string,
) (*bool, *bool, error) {
unknownFlag := &IRCError{
irc.ErrUmodeUnknownFlag, nil, "Unknown MODE flag",
}
if !strings.HasPrefix(modeStr, "+") &&
!strings.HasPrefix(modeStr, "-") {
return nil, nil, unknownFlag
}
var wallops, oper *bool
adding := true
for _, modeChar := range modeStr {
switch modeChar {
case '+':
adding = true
case '-':
adding = false
case 'w':
value := adding
wallops = &value
case 'o':
if adding {
return nil, nil, unknownFlag
}
value := false
oper = &value
default:
return nil, nil, unknownFlag
}
}
if wallops == nil && oper == nil {
return nil, nil, unknownFlag
}
return wallops, oper, nil
}
// requireOper returns ERR_NOPRIVILEGES unless the session
// is a server operator.
func (s *Service) requireOper(
ctx context.Context,
sessionID int64,
) error {
isOper, err := s.db.IsSessionOper(ctx, sessionID)
if err != nil {
return fmt.Errorf("check oper: %w", err)
}
if !isOper {
return &IRCError{
irc.ErrNoPrivileges,
nil,
"Permission Denied- You're not an IRC operator",
}
}
return nil
}
// broadcastNickChange notifies channel peers of a nick
// change.
func (s *Service) broadcastNickChange(