Fix IRC listener relay of bodies and modes, and JOIN names (closes #127)
check / check (push) Canceled after 0s

The IRC listener relayed only the first line of a PRIVMSG or NOTICE
body; it now sends one line per body line. A MODE change reached IRC
listener members without its key, limit or mask, and "+o bob" went out
as one trailing parameter; each value is now its own parameter, in the
relay and in the echo to the IRC listener user who made the change. The
channel name check moved from the HTTP handler into
service.JoinChannel, so the IRC listener's JOIN refuses the same names
with 403.

Model: opus-5-5
This commit is contained in:
2026-10-20 09:23:58 +00:00
parent eea90b01e5
commit 275670d092
7 changed files with 236 additions and 44 deletions
+13 -14
View File
@@ -2656,7 +2656,9 @@ IRC_LISTEN_ADDR: ""
- **CAP negotiation**: `CAP LS` gets an empty capability list and `CAP END` is - **CAP negotiation**: `CAP LS` gets an empty capability list and `CAP END` is
ignored, for compatibility with modern clients. ignored, for compatibility with modern clients.
- **Channel prefixes**: Channels must start with `#`. `JOIN` prepends it if - **Channel prefixes**: Channels must start with `#`. `JOIN` prepends it if
omitted; other commands use the name as given. omitted; other commands use the name as given. As over the HTTP API, `JOIN`
answers a name that is not `#` followed by 1–63 letters, digits, `_` or `-`
with ERR_NOSUCHCHANNEL (403).
- **First joiner**: The first user to join a channel is automatically granted - **First joiner**: The first user to join a channel is automatically granted
operator status (`@`). operator status (`@`).
- **Channel modes**: `+i` (invite-only), `+m` (moderated), `+n` (no external), - **Channel modes**: `+i` (invite-only), `+m` (moderated), `+n` (no external),
@@ -2678,21 +2680,18 @@ IRC_LISTEN_ADDR: ""
Messages sent by IRC clients appear in channels visible to HTTP/JSON API clients Messages sent by IRC clients appear in channels visible to HTTP/JSON API clients
and vice versa. The IRC listener and HTTP API share the same database, broker, and vice versa. The IRC listener and HTTP API share the same database, broker,
and session infrastructure, so a user connected via IRC and a user connected via and session infrastructure, so a user connected via IRC and a user connected via
the HTTP API can talk in the same channels, with these gaps today: the HTTP API can talk in the same channels.
- An IRC listener user receives only the first line of a multi-line `body`. Each line of a multi-line `body` reaches an IRC listener user as its own
- A `+k`, `+l` or `+b` change made over the HTTP API reaches IRC listener `PRIVMSG` or `NOTICE` line. A mode change made over the HTTP API reaches IRC
members without its key, limit or mask. listener members as an IRC `MODE` line with its value as a separate parameter,
- The IRC listener's `JOIN` accepts any channel name, while over the HTTP API a e.g. `MODE #general +k secretpass` or `MODE #general +o bob`.
name that is not `#` followed by 1–63 letters, digits, `_` or `-` gets
ERR_NOSUCHCHANNEL (403), so HTTP API users cannot join such a channel.
- The IRC listener does not pass on a `PRIVMSG`, `NOTICE`, `JOIN`, `PART`,
`NICK` or `QUIT` whose sender's nick matches the user's own nick ignoring
case, so an IRC listener user `alice` never sees these from `Alice`, although
nicks are case-sensitive.
https://git.eeqj.de/sneak/neoirc/issues/127 tracks the first three and One gap remains: the IRC listener does not pass on a `PRIVMSG`, `NOTICE`,
https://git.eeqj.de/sneak/neoirc/issues/128 the last. `JOIN`, `PART`, `NICK` or `QUIT` whose sender's nick matches the user's own nick
ignoring case, so an IRC listener user `alice` never sees these from `Alice`,
although nicks are case-sensitive. https://git.eeqj.de/sneak/neoirc/issues/128
tracks it.
### Docker Usage ### Docker Usage
-14
View File
@@ -28,10 +28,6 @@ var validNickRe = regexp.MustCompile(
`^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`, `^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`,
) )
var validChannelRe = regexp.MustCompile(
`^#[a-zA-Z0-9_\-]{1,63}$`,
)
var validUsernameRe = regexp.MustCompile( var validUsernameRe = regexp.MustCompile(
`^[a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{1,32}$`, `^[a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{1,32}$`,
) )
@@ -1488,16 +1484,6 @@ func (hdlr *Handlers) handleJoin(
channel = "#" + channel channel = "#" + channel
} }
if !validChannelRe.MatchString(channel) {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoSuchChannel, nick, []string{channel},
"No such channel",
)
return
}
// Extract key from body lines (JOIN #channel key). // Extract key from body lines (JOIN #channel key).
var suppliedKey string var suppliedKey string
+6 -3
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -683,9 +684,11 @@ func (c *Conn) applyChannelModes(
if applied.Len() > 0 { if applied.Len() > 0 {
modeReply := applied.String() + appliedArgs.String() modeReply := applied.String() + appliedArgs.String()
c.send(FormatMessage( params := slices.Concat(
c.hostmask(), "MODE", channel, modeReply, []string{channel}, strings.Fields(modeReply),
)) )
c.send(FormatMessage(c.hostmask(), "MODE", params...))
c.svc.BroadcastMode( c.svc.BroadcastMode(
ctx, c.nick, channel, chID, modeReply, ctx, c.nick, channel, chID, modeReply,
+28 -12
View File
@@ -3,6 +3,7 @@ package ircserver
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"slices"
"strings" "strings"
"time" "time"
@@ -105,7 +106,7 @@ func (c *Conn) deliverIRCMessage(
case isNumeric(command): case isNumeric(command):
c.deliverNumeric(msg, text) c.deliverNumeric(msg, text)
case command == irc.CmdPrivmsg || command == irc.CmdNotice: case command == irc.CmdPrivmsg || command == irc.CmdNotice:
c.deliverTextMessage(msg, command, text) c.deliverTextMessage(msg, command, bodyLines)
case command == irc.CmdJoin: case command == irc.CmdJoin:
c.deliverJoin(msg) c.deliverJoin(msg)
case command == irc.CmdPart: case command == irc.CmdPart:
@@ -123,7 +124,7 @@ func (c *Conn) deliverIRCMessage(
case command == irc.CmdWallops: case command == irc.CmdWallops:
c.deliverWallops(msg, text) c.deliverWallops(msg, text)
case command == irc.CmdMode: case command == irc.CmdMode:
c.deliverMode(msg, text) c.deliverMode(msg, bodyLines)
case command == irc.CmdPing: case command == irc.CmdPing:
// Server-originated PING — reply with PONG. // Server-originated PING — reply with PONG.
c.sendFromServer("PING", c.serverSfx) c.sendFromServer("PING", c.serverSfx)
@@ -179,10 +180,12 @@ func (c *Conn) deliverNumeric(
c.send(FormatMessage(from, msg.Command, allParams...)) c.send(FormatMessage(from, msg.Command, allParams...))
} }
// deliverTextMessage sends PRIVMSG or NOTICE. // deliverTextMessage sends PRIVMSG or NOTICE, one line for
// each line of the body.
func (c *Conn) deliverTextMessage( func (c *Conn) deliverTextMessage(
msg *db.IRCMessage, msg *db.IRCMessage,
command, text string, command string,
bodyLines []string,
) { ) {
from := msg.From from := msg.From
target := msg.To target := msg.To
@@ -201,7 +204,9 @@ func (c *Conn) deliverTextMessage(
prefix = from + "!" + from + "@*" prefix = from + "!" + from + "@*"
} }
c.send(FormatMessage(prefix, command, target, text)) for _, line := range bodyLines {
c.send(FormatMessage(prefix, command, target, line))
}
} }
// deliverJoin sends a JOIN notification. // deliverJoin sends a JOIN notification.
@@ -351,15 +356,26 @@ func (c *Conn) deliverWallops(
)) ))
} }
// deliverMode sends a MODE change notification. // deliverMode sends a MODE change notification. The first
// body line is the change, possibly followed by its values
// separated by spaces ("+o bob"); any further lines are
// values ("+k", "secret"). Each goes out as its own
// parameter, as IRC sends them.
func (c *Conn) deliverMode( func (c *Conn) deliverMode(
msg *db.IRCMessage, msg *db.IRCMessage,
text string, bodyLines []string,
) { ) {
prefix := msg.From + "!" + msg.From + "@*" if len(bodyLines) == 0 || bodyLines[0] == "" {
target := msg.To return
if text != "" {
c.send(FormatMessage(prefix, "MODE", target, text))
} }
prefix := msg.From + "!" + msg.From + "@*"
params := slices.Concat(
[]string{msg.To},
strings.Fields(bodyLines[0]),
bodyLines[1:],
)
c.send(FormatMessage(prefix, "MODE", params...))
} }
+141
View File
@@ -0,0 +1,141 @@
package ircserver_test
import (
"encoding/json"
"slices"
"strings"
"testing"
)
// queueMessage queues a message for the user with nick
// recipient the way the HTTP API sends one: stored once, then
// queued to the recipient's session.
func (env *testEnv) queueMessage(
t *testing.T,
recipient, command, from, to string,
body []string,
) {
t.Helper()
ctx := t.Context()
bodyJSON, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
msgID, _, err := env.database.InsertMessage(
ctx, command, from, to, nil, bodyJSON, nil,
)
if err != nil {
t.Fatalf("insert message: %v", err)
}
sessionID, err := env.database.GetSessionByNick(ctx, recipient)
if err != nil {
t.Fatalf("get session of %s: %v", recipient, err)
}
err = env.database.EnqueueToSession(ctx, sessionID, msgID)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
env.brk.Notify(sessionID)
}
// TestRelayMultiLineBody checks that each line of a PRIVMSG
// or NOTICE body sent over the HTTP API reaches an IRC
// listener user as its own line.
func TestRelayMultiLineBody(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
for _, command := range []string{"PRIVMSG", "NOTICE"} {
env.queueMessage(
t, "alice", command, "carol", "alice",
[]string{"first line", "second line"},
)
lines := alice.readUntil(func(l string) bool {
return strings.Contains(l, "second line")
})
want := []string{
":carol!carol@* " + command + " alice :first line",
":carol!carol@* " + command + " alice :second line",
}
got := lines[max(0, len(lines)-len(want)):]
if !slices.Equal(got, want) {
t.Errorf("%s: got %q, want %q", command, got, want)
}
}
}
// TestRelayModeParameters checks that a MODE change reaches
// IRC listener members with its values as separate
// parameters, both for the bodies the HTTP API stores and
// for a change made on the IRC listener.
func TestRelayModeParameters(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
for _, step := range []struct {
body []string
want string
}{
{[]string{"+k", "secret"}, "+k secret"},
{[]string{"-k", "*"}, "-k *"},
{[]string{"+l", "50"}, "+l 50"},
{[]string{"-l"}, "-l"},
{[]string{"+b", "*!*@*.example.com"}, "+b *!*@*.example.com"},
{[]string{"-b", "*!*@*.example.com"}, "-b *!*@*.example.com"},
{[]string{"+o bob"}, "+o bob"},
{[]string{"+m"}, "+m"},
} {
env.queueMessage(t, "alice", "MODE", "op", "#modes", step.body)
lines := alice.readUntil(func(l string) bool {
return strings.Contains(l, " MODE ")
})
got := lines[len(lines)-1]
want := ":op!op@* MODE #modes " + step.want
if got != want {
t.Errorf("body %q: got %q, want %q", step.body, got, want)
}
}
bob := env.dial(t)
bob.register("bob")
alice.joinAndDrain("#modes")
bob.joinAndDrain("#modes")
alice.send("MODE #modes +v bob")
const change = " MODE #modes +v bob"
for name, client := range map[string]*testClient{
"alice": alice, "bob": bob,
} {
lines := client.readUntil(func(l string) bool {
return strings.Contains(l, " MODE ")
})
got := lines[len(lines)-1]
if !strings.HasSuffix(got, change) {
t.Errorf("%s: got %q, want it to end in %q", name, got, change)
}
}
}
+31
View File
@@ -438,6 +438,37 @@ func TestJoinChannel(t *testing.T) {
assertContains(t, lines, " 366 ", "RPL_ENDOFNAMES") assertContains(t, lines, " 366 ", "RPL_ENDOFNAMES")
} }
// TestJoinInvalidChannelName checks that JOIN refuses a name
// that is not # followed by 1 to 63 letters, digits, _ or -
// with ERR_NOSUCHCHANNEL, and creates no such channel.
func TestJoinInvalidChannelName(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("joiner")
for _, channel := range []string{
"#bad.name",
"#",
"#" + strings.Repeat("a", 64),
} {
lines := client.sendAndExpect("JOIN "+channel, " 403 ")
assertContains(
t, lines,
" 403 joiner "+channel+" :No such channel",
channel,
)
_, err := env.database.GetChannelByName(t.Context(), channel)
if err == nil {
t.Errorf("channel %q was created", channel)
}
}
}
func TestPrivmsgBetweenClients(t *testing.T) { func TestPrivmsgBetweenClients(t *testing.T) {
t.Parallel() t.Parallel()
+17 -1
View File
@@ -10,6 +10,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"regexp"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -35,6 +36,12 @@ const (
// for (RFC 2812). // for (RFC 2812).
const maxUserhostNicks = 5 const maxUserhostNicks = 5
// validChannelRe matches the channel names JOIN accepts: #
// followed by 1 to 63 letters, digits, _ or -.
var validChannelRe = regexp.MustCompile(
`^#[a-zA-Z0-9_\-]{1,63}$`,
)
// Params defines the dependencies for creating a Service. // Params defines the dependencies for creating a Service.
type Params struct { type Params struct {
fx.In fx.In
@@ -473,12 +480,21 @@ func (s *Service) SendDirectMessage(
// JoinChannel creates or joins a channel, making the // JoinChannel creates or joins a channel, making the
// first joiner the operator. Fans out the JOIN to all // first joiner the operator. Fans out the JOIN to all
// channel members. // channel members. A channel name that validChannelRe
// does not match gets ERR_NOSUCHCHANNEL.
func (s *Service) JoinChannel( func (s *Service) JoinChannel(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID int64,
nick, channel, suppliedKey string, nick, channel, suppliedKey string,
) (*JoinResult, error) { ) (*JoinResult, error) {
if !validChannelRe.MatchString(channel) {
return nil, &IRCError{
irc.ErrNoSuchChannel,
[]string{channel},
msgNoSuchChannel,
}
}
chID, err := s.db.GetOrCreateChannel(ctx, channel) chID, err := s.db.GetOrCreateChannel(ctx, channel)
if err != nil { if err != nil {
return nil, fmt.Errorf("get/create channel: %w", err) return nil, fmt.Errorf("get/create channel: %w", err)