1 Commits
Author SHA1 Message Date
clawbot e28fa005ed Add the tier 3 utility IRC commands (closes #87)
check / check (push) Canceled after 0s
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
2026-10-08 00:26:08 +00:00
44 changed files with 4309 additions and 9087 deletions
+474 -814
View File
File diff suppressed because it is too large Load Diff
-10
View File
@@ -47,16 +47,6 @@ func (b *Broker) Notify(userID int64) {
} }
} }
// ListenerCount returns how many channels from Wait are still
// registered for the user: those that neither Notify nor Remove
// has taken out.
func (b *Broker) ListenerCount(userID int64) int {
b.mu.Lock()
defer b.mu.Unlock()
return len(b.listeners[userID])
}
// Remove removes a specific wait channel (for cleanup on timeout). // Remove removes a specific wait channel (for cleanup on timeout).
func (b *Broker) Remove( func (b *Broker) Remove(
userID int64, userID int64,
-33
View File
@@ -59,39 +59,6 @@ func TestRemove(t *testing.T) {
} }
} }
func TestListenerCount(t *testing.T) {
t.Parallel()
brk := broker.New()
wantCount := func(step string, userID int64, want int) {
t.Helper()
got := brk.ListenerCount(userID)
if got != want {
t.Errorf("%s: user %d: got %d, want %d",
step, userID, got, want)
}
}
wantCount("before Wait", 1, 0)
waitCh := brk.Wait(1)
brk.Wait(1)
brk.Wait(2)
wantCount("after two Waits", 1, 2)
brk.Remove(1, waitCh)
wantCount("after Remove", 1, 1)
brk.Notify(1)
wantCount("after Notify", 1, 0)
wantCount("after Notify", 2, 1)
}
func TestMultipleWaiters(t *testing.T) { func TestMultipleWaiters(t *testing.T) {
t.Parallel() t.Parallel()
+2 -2
View File
@@ -250,7 +250,7 @@ func (client *Client) ListChannels() (
// GetMembers returns members of a channel. // GetMembers returns members of a channel.
func (client *Client) GetMembers( func (client *Client) GetMembers(
channel string, channel string,
) ([]Member, error) { ) ([]string, error) {
name := strings.TrimPrefix(channel, "#") name := strings.TrimPrefix(channel, "#")
data, err := client.do( data, err := client.do(
@@ -263,7 +263,7 @@ func (client *Client) GetMembers(
return nil, err return nil, err
} }
var members []Member var members []string
err = json.Unmarshal(data, &members) err = json.Unmarshal(data, &members)
if err != nil { if err != nil {
+7 -366
View File
@@ -2,28 +2,18 @@ package neoircapi_test
import ( import (
"context" "context"
"encoding/json"
"fmt"
"io" "io"
"net" "net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"reflect"
"slices"
"strconv"
"strings" "strings"
"testing" "testing"
api "sneak.berlin/go/neoirc/internal/cli/api" api "sneak.berlin/go/neoirc/internal/cli/api"
"sneak.berlin/go/neoirc/internal/hashcash"
) )
const ( const cookieValue = "opaque-value"
cookieValue = "opaque-value"
testNick = "alice"
testChannel = "#general"
)
// newSessionServer starts a plain-HTTP server that, like // newSessionServer starts a plain-HTTP server that, like
// neoircd, sets a Secure auth cookie when a session is // neoircd, sets a Secure auth cookie when a session is
@@ -34,16 +24,10 @@ func newSessionServer(t *testing.T) *httptest.Server {
mux := http.NewServeMux() mux := http.NewServeMux()
// Without hashcash the server sends no hashcash_bits.
mux.HandleFunc("GET /api/v1/server", func( mux.HandleFunc("GET /api/v1/server", func(
writer http.ResponseWriter, _ *http.Request, writer http.ResponseWriter, _ *http.Request,
) { ) {
_, _ = io.WriteString(writer, `{ _, _ = io.WriteString(writer, `{}`)
"name": "My NeoIRC Server",
"version": "0.1.0",
"motd": "Welcome! Be nice.",
"users": 42
}`)
}) })
mux.HandleFunc("POST /api/v1/session", func( mux.HandleFunc("POST /api/v1/session", func(
@@ -72,14 +56,9 @@ func newSessionServer(t *testing.T) *httptest.Server {
return return
} }
_, _ = io.WriteString(writer, `{ _, _ = io.WriteString(
"id": 1, writer, `{"id":1,"nick":"alice","channels":[]}`,
"nick": "alice", )
"channels": [
{"id": 1, "name": "#general", "topic": "Welcome!"},
{"id": 2, "name": "#dev", "topic": ""}
]
}`)
}) })
server := httptest.NewServer(mux) server := httptest.NewServer(mux)
@@ -88,344 +67,6 @@ func newSessionServer(t *testing.T) *httptest.Server {
return server return server
} }
// newClientForPath starts a server that answers GET path with
// body, and returns a client for it.
func newClientForPath(t *testing.T, path, body string) *api.Client {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("GET "+path, func(
writer http.ResponseWriter, _ *http.Request,
) {
_, _ = io.WriteString(writer, body)
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
return api.NewClient(server.URL)
}
func TestGetStateDecodesFields(t *testing.T) {
t.Parallel()
client := api.NewClient(newSessionServer(t).URL)
_, err := client.CreateSession(testNick)
if err != nil {
t.Fatalf("create session: %v", err)
}
state, err := client.GetState()
if err != nil {
t.Fatalf("state: %v", err)
}
want := api.StateResponse{
ID: 1,
Nick: testNick,
Channels: []api.Channel{
{Name: testChannel, Topic: "Welcome!"},
{Name: "#dev", Topic: ""},
},
}
if !reflect.DeepEqual(*state, want) {
t.Fatalf("state: got %+v, want %+v", *state, want)
}
}
func TestGetServerInfoDecodesFields(t *testing.T) {
t.Parallel()
client := newClientForPath(t, "/api/v1/server", `{
"name": "My NeoIRC Server",
"version": "0.1.0",
"motd": "Welcome! Be nice.",
"users": 42,
"hashcash_bits": 20
}`)
info, err := client.GetServerInfo()
if err != nil {
t.Fatalf("server info: %v", err)
}
want := api.ServerInfo{
Name: "My NeoIRC Server",
MOTD: "Welcome! Be nice.",
Version: "0.1.0",
HashcashBits: 20,
}
if *info != want {
t.Fatalf("server info: got %+v, want %+v", *info, want)
}
}
// TestCreateSessionSendsNickAndStamp serves a server that, like
// neoircd with hashcash on, advertises hashcash_bits and creates
// the session only for a pow_token the server's validator in
// internal/hashcash accepts.
func TestCreateSessionSendsNickAndStamp(t *testing.T) {
t.Parallel()
const (
serverName = "My NeoIRC Server"
stampBits = 8
sessionID = 7
)
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/server", func(
writer http.ResponseWriter, _ *http.Request,
) {
_, _ = fmt.Fprintf(writer, `{
"name": %q,
"version": "0.1.0",
"motd": "Welcome! Be nice.",
"users": 42,
"hashcash_bits": %d
}`, serverName, stampBits)
})
validator := hashcash.NewValidator(serverName)
mux.HandleFunc("POST /api/v1/session", func(
writer http.ResponseWriter, request *http.Request,
) {
// Decoded with the field names handleCreateSession uses.
var payload struct {
Nick string `json:"nick"`
Hashcash string `json:"pow_token"` //nolint:tagliatelle
}
err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil {
writer.WriteHeader(http.StatusBadRequest)
return
}
err = validator.Claim(payload.Hashcash, stampBits)
if err != nil {
writer.WriteHeader(http.StatusPaymentRequired)
return
}
writer.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(writer).Encode(map[string]any{
"id": sessionID,
"nick": payload.Nick,
})
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
session, err := api.NewClient(server.URL).CreateSession(testNick)
if err != nil {
t.Fatalf("create session: %v", err)
}
want := api.SessionResponse{ID: sessionID, Nick: testNick}
if *session != want {
t.Fatalf("session: got %+v, want %+v", *session, want)
}
}
func TestListChannelsDecodesNamesAndTopics(t *testing.T) {
t.Parallel()
client := newClientForPath(t, "/api/v1/channels", `[
{"id": 1, "name": "#general", "topic": "Welcome!"},
{"id": 2, "name": "#dev", "topic": "Development discussion"}
]`)
channels, err := client.ListChannels()
if err != nil {
t.Fatalf("list channels: %v", err)
}
want := []api.Channel{
{Name: testChannel, Topic: "Welcome!"},
{Name: "#dev", Topic: "Development discussion"},
}
if !slices.Equal(channels, want) {
t.Fatalf("channels: got %+v, want %+v", channels, want)
}
}
func TestGetMembersDecodesMemberObjects(t *testing.T) {
t.Parallel()
client := newClientForPath(t, "/api/v1/channels/general/members", `[
{
"id": 1, "nick": "alice", "username": "alice",
"hostname": "host.example.com",
"isOperator": true, "isVoiced": false,
"lastSeen": "2026-02-10T20:00:00Z"
},
{
"id": 2, "nick": "bob", "username": "bob",
"hostname": "host.example.com",
"isOperator": false, "isVoiced": true,
"lastSeen": "2026-02-10T20:00:00Z"
},
{
"id": 3, "nick": "Carol", "username": "carol",
"hostname": "host.example.com",
"isOperator": false, "isVoiced": false,
"lastSeen": "2026-02-10T20:00:00Z"
}
]`)
members, err := client.GetMembers(testChannel)
if err != nil {
t.Fatalf("get members: %v", err)
}
want := []api.Member{
{Nick: testNick, IsOperator: true, IsVoiced: false},
{Nick: "bob", IsOperator: false, IsVoiced: true},
{Nick: "Carol", IsOperator: false, IsVoiced: false},
}
if !slices.Equal(members, want) {
t.Fatalf("members: got %+v, want %+v", members, want)
}
}
// TestPollMessagesAdvancesCursor serves the queue as the server
// does: entries after the cursor, and last_id as the new cursor.
func TestPollMessagesAdvancesCursor(t *testing.T) {
t.Parallel()
const lastQueueID = 42
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/messages", func(
writer http.ResponseWriter, request *http.Request,
) {
after := request.URL.Query().Get("after")
if after == strconv.Itoa(lastQueueID) {
_, _ = io.WriteString(
writer, `{"messages": [], "last_id": 42}`,
)
return
}
_, _ = io.WriteString(writer, `{
"messages": [{
"id": "b7c8210f-849c-4b90-9ee8-d99c8889358e",
"command": "PRIVMSG",
"from": "alice",
"to": "#general",
"body": ["hello world"],
"ts": "2026-02-10T20:00:01.234567891Z",
"meta": {}
}],
"last_id": 42
}`)
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
client := api.NewClient(server.URL)
first, err := client.PollMessages(0, 0)
if err != nil {
t.Fatalf("first poll: %v", err)
}
if first.LastID != lastQueueID || len(first.Messages) != 1 {
t.Fatalf(
"first poll: got last ID %d and %d messages, "+
"want %d and 1",
first.LastID, len(first.Messages), lastQueueID,
)
}
second, err := client.PollMessages(first.LastID, 0)
if err != nil {
t.Fatalf("second poll: %v", err)
}
if len(second.Messages) != 0 {
t.Fatalf(
"second poll: got %d messages again, want none",
len(second.Messages),
)
}
}
// TestPollMessagesDecodesMessageFields serves a relayed PRIVMSG
// with the sender's meta and a numeric, as the server queues
// them.
func TestPollMessagesDecodesMessageFields(t *testing.T) {
t.Parallel()
client := newClientForPath(t, "/api/v1/messages", `{
"messages": [
{
"id": "b7c8210f-849c-4b90-9ee8-d99c8889358e",
"command": "PRIVMSG",
"from": "alice",
"to": "#general",
"body": ["signed message"],
"ts": "2026-02-10T20:00:01.234567891Z",
"meta": {"sig": "base64...", "alg": "ed25519"}
},
{
"id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5",
"command": "RPL_LUSEROP",
"code": 252,
"from": "neoirc",
"to": "alice",
"params": ["0"],
"body": ["operator(s) online"],
"ts": "2026-02-10T20:00:02Z",
"meta": {}
}
],
"last_id": 42
}`)
result, err := client.PollMessages(0, 0)
if err != nil {
t.Fatalf("poll: %v", err)
}
want := []api.Message{
{
ID: "b7c8210f-849c-4b90-9ee8-d99c8889358e",
Command: "PRIVMSG",
From: testNick,
To: testChannel,
Body: []any{"signed message"},
TS: "2026-02-10T20:00:01.234567891Z",
Meta: map[string]any{"sig": "base64...", "alg": "ed25519"},
},
{
ID: "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5",
Command: "RPL_LUSEROP",
From: "neoirc",
To: testNick,
Params: []string{"0"},
Body: []any{"operator(s) online"},
TS: "2026-02-10T20:00:02Z",
Meta: map[string]any{},
},
}
if !reflect.DeepEqual(result.Messages, want) {
t.Fatalf("messages: got %+v, want %+v", result.Messages, want)
}
}
func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) { func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) {
t.Parallel() t.Parallel()
@@ -444,7 +85,7 @@ func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) {
"http://" + net.JoinHostPort(host, serverURL.Port()), "http://" + net.JoinHostPort(host, serverURL.Port()),
) )
_, err := client.CreateSession(testNick) _, err := client.CreateSession("alice")
if err != nil { if err != nil {
t.Fatalf("create session: %v", err) t.Fatalf("create session: %v", err)
} }
@@ -477,7 +118,7 @@ func TestClientWithholdsCookieOverPlainHTTPFromOtherHosts(t *testing.T) {
}, },
} }
_, err := client.CreateSession(testNick) _, err := client.CreateSession("alice")
if err != nil { if err != nil {
t.Fatalf("create session: %v", err) t.Fatalf("create session: %v", err)
} }
+9 -16
View File
@@ -16,9 +16,9 @@ type SessionResponse struct {
// StateResponse is the response from GET /api/v1/state. // StateResponse is the response from GET /api/v1/state.
type StateResponse struct { type StateResponse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Nick string `json:"nick"` Nick string `json:"nick"`
Channels []Channel `json:"channels"` Channels []string `json:"channels"`
} }
// Message represents a neoirc message envelope. // Message represents a neoirc message envelope.
@@ -53,19 +53,12 @@ func (m *Message) BodyLines() []string {
} }
} }
// Channel is a channel as GET /api/v1/channels and // Channel represents a channel in the list response.
// GET /api/v1/state describe it.
type Channel struct { type Channel struct {
Name string `json:"name"` Name string `json:"name"`
Topic string `json:"topic"` Topic string `json:"topic"`
} Members int `json:"members"`
CreatedAt string `json:"createdAt"`
// Member is one entry of the response from
// GET /api/v1/channels/{name}/members.
type Member struct {
Nick string `json:"nick"`
IsOperator bool `json:"isOperator"`
IsVoiced bool `json:"isVoiced"`
} }
// ServerInfo is the response from GET /api/v1/server. // ServerInfo is the response from GET /api/v1/server.
@@ -79,7 +72,7 @@ type ServerInfo struct {
// MessagesResponse wraps polling results. // MessagesResponse wraps polling results.
type MessagesResponse struct { type MessagesResponse struct {
Messages []Message `json:"messages"` Messages []Message `json:"messages"`
LastID int64 `json:"last_id"` //nolint:tagliatelle LastID int64 `json:"lastId"`
} }
// PollResult wraps the poll response including the cursor. // PollResult wraps the poll response including the cursor.
+16 -32
View File
@@ -434,9 +434,16 @@ func (a *App) cmdTopic(args string) {
return return
} }
// The HTTP API has no topic query: a TOPIC needs text.
if args == "" { if args == "" {
a.ui.AddStatus("[red]Usage: /topic <text>") err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: irc.CmdTopic,
To: target,
})
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
"[red]Topic query failed: %v", err,
))
}
return return
} }
@@ -480,32 +487,12 @@ func (a *App) cmdNames() {
return return
} }
nicks := make([]string, 0, len(members))
for _, member := range members {
nicks = append(nicks, memberPrefix(member)+member.Nick)
}
a.ui.AddLine(target, fmt.Sprintf( a.ui.AddLine(target, fmt.Sprintf(
"[cyan]*** Members of %s: %s", "[cyan]*** Members of %s: %s",
target, strings.Join(nicks, " "), target, strings.Join(members, " "),
)) ))
} }
// memberPrefix returns "@" for an operator, "+" for a voiced
// member, and "" for anyone else.
func memberPrefix(member api.Member) string {
if member.IsOperator {
return "@"
}
if member.IsVoiced {
return "+"
}
return ""
}
func (a *App) cmdList() { func (a *App) cmdList() {
a.mu.Lock() a.mu.Lock()
connected := a.connected connected := a.connected
@@ -530,7 +517,8 @@ func (a *App) cmdList() {
for _, ch := range channels { for _, ch := range channels {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
" %s %s", ch.Name, ch.Topic, " %s (%d members) %s",
ch.Name, ch.Members, ch.Topic,
)) ))
} }
@@ -688,7 +676,7 @@ func (a *App) cmdHelp() {
" /part [#chan] — Leave channel", " /part [#chan] — Leave channel",
" /msg <nick> <text> — Send DM", " /msg <nick> <text> — Send DM",
" /query <nick> — Open DM window", " /query <nick> — Open DM window",
" /topic <text> — Set topic", " /topic [text] — View/set topic",
" /names — List channel members", " /names — List channel members",
" /list — List channels", " /list — List channels",
" /who [#channel] — List users in channel", " /who [#channel] — List users in channel",
@@ -757,7 +745,7 @@ func (a *App) handleServerMessage(msg *api.Message) {
case irc.CmdJoin: case irc.CmdJoin:
a.handleJoinEvent(msg, timestamp) a.handleJoinEvent(msg, timestamp)
case irc.CmdPart: case irc.CmdPart:
a.handlePartEvent(msg, timestamp, myNick) a.handlePartEvent(msg, timestamp)
case irc.CmdQuit: case irc.CmdQuit:
a.handleQuitEvent(msg, timestamp) a.handleQuitEvent(msg, timestamp)
case irc.CmdNick: case irc.CmdNick:
@@ -813,14 +801,10 @@ func (a *App) handleJoinEvent(
)) ))
} }
// handlePartEvent shows another user's part. The server
// also sends the user's own part back to it, which cmdPart
// has already shown; showing it again would add a second
// line and mark the channel just left as unread.
func (a *App) handlePartEvent( func (a *App) handlePartEvent(
msg *api.Message, timestamp, myNick string, msg *api.Message, timestamp string,
) { ) {
if msg.To == "" || msg.From == myNick { if msg.To == "" {
return return
} }
-116
View File
@@ -1,116 +0,0 @@
package cli
import (
"testing"
"github.com/gdamore/tcell/v2"
api "sneak.berlin/go/neoirc/internal/cli/api"
"sneak.berlin/go/neoirc/pkg/irc"
)
func TestMemberPrefix(t *testing.T) {
t.Parallel()
tests := []struct {
name string
member api.Member
want string
}{
{
name: "operator",
member: api.Member{Nick: "alice", IsOperator: true},
want: "@",
},
{
name: "voiced member",
member: api.Member{Nick: "bob", IsVoiced: true},
want: "+",
},
{
name: "voiced operator",
member: api.Member{
Nick: "Carol", IsOperator: true, IsVoiced: true,
},
want: "@",
},
{
name: "plain member",
member: api.Member{Nick: "dave"},
want: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
got := memberPrefix(test.member)
if got != test.want {
t.Fatalf("got %q, want %q", got, test.want)
}
})
}
}
// TestPartEvent checks that a PART event adds a line to the
// channel's buffer and marks it unread, except for the
// user's own part, which cmdPart has already shown.
func TestPartEvent(t *testing.T) {
t.Parallel()
tests := []struct {
name string
from string
wantLines int
wantUnread int
}{
{name: "own part", from: "erin", wantLines: 0, wantUnread: 0},
{name: "other member", from: "bob", wantLines: 1, wantUnread: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
app := &App{ui: NewUI(), nick: "erin"}
runUI(t, app.ui)
app.handleServerMessage(&api.Message{
Command: irc.CmdPart, From: test.from, To: "#chan",
})
var lines, unread int
app.ui.app.QueueUpdate(func() {
buf := app.ui.getOrCreateBuffer("#chan")
lines = len(buf.Lines)
unread = buf.Unread
})
if lines != test.wantLines || unread != test.wantUnread {
t.Fatalf("got %d lines and %d unread, want %d and %d",
lines, unread, test.wantLines, test.wantUnread)
}
})
}
}
// runUI runs ui on a simulated screen until the test ends.
func runUI(t *testing.T, ui *UI) {
t.Helper()
ui.app.SetScreen(tcell.NewSimulationScreen(""))
done := make(chan error)
go func() { done <- ui.Run() }()
t.Cleanup(func() {
ui.Stop()
err := <-done
if err != nil {
t.Errorf("run ui: %v", err)
}
})
}
+1 -15
View File
@@ -4,7 +4,6 @@ package config
import ( import (
"errors" "errors"
"log/slog" "log/slog"
"os"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/fx" "go.uber.org/fx"
@@ -22,12 +21,6 @@ const defaultMOTD = ` _ __ ___ ___ (_)_ __ ___
Welcome to NeoIRC — IRC semantics over HTTP. Welcome to NeoIRC — IRC semantics over HTTP.
Type /help for available commands.` Type /help for available commands.`
// DefaultDBURL is the database URL used when DBURL is not set. Its
// _pragma parameters, which the SQLite driver runs on every connection
// it opens, set WAL mode and a 5000 ms busy timeout.
const DefaultDBURL = "file:///var/lib/neoirc/state.db" +
"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
// Params defines the dependencies for creating a Config. // Params defines the dependencies for creating a Config.
type Params struct { type Params struct {
fx.In fx.In
@@ -78,7 +71,7 @@ func New(
viper.SetDefault("DEBUG", "false") viper.SetDefault("DEBUG", "false")
viper.SetDefault("MAINTENANCE_MODE", "false") viper.SetDefault("MAINTENANCE_MODE", "false")
viper.SetDefault("PORT", "8080") viper.SetDefault("PORT", "8080")
viper.SetDefault("DBURL", DefaultDBURL) viper.SetDefault("DBURL", "file:///var/lib/neoirc/state.db?_journal_mode=WAL")
viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "") viper.SetDefault("METRICS_PASSWORD", "")
@@ -130,13 +123,6 @@ func New(
params: &params, params: &params,
} }
// Viper counts an empty environment variable as unset, but an
// empty IRC_LISTEN_ADDR must turn the IRC listener off.
ircListenAddr, set := os.LookupEnv("IRC_LISTEN_ADDR")
if set {
cfg.IRCListenAddr = ircListenAddr
}
if cfg.Debug { if cfg.Debug {
params.Logger.EnableDebugLogging() params.Logger.EnableDebugLogging()
cfg.log = params.Logger.Get() cfg.log = params.Logger.Get()
-61
View File
@@ -1,61 +0,0 @@
package config_test
import (
"os"
"testing"
"time"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
)
func newConfig(t *testing.T) *config.Config {
t.Helper()
globs := &globals.Globals{
Appname: "neoirc-test",
Version: "test",
StartTime: time.Now(),
}
log, err := logger.New(nil, logger.Params{Globals: globs})
if err != nil {
t.Fatal(err)
}
cfg, err := config.New(
nil, config.Params{Globals: globs, Logger: log},
)
if err != nil {
t.Fatal(err)
}
return cfg
}
func TestIRCListenAddrUnsetKeepsDefault(t *testing.T) {
// t.Setenv puts back whatever value was there after the test.
t.Setenv("IRC_LISTEN_ADDR", "")
err := os.Unsetenv("IRC_LISTEN_ADDR")
if err != nil {
t.Fatal(err)
}
const want = ":6667"
cfg := newConfig(t)
if cfg.IRCListenAddr != want {
t.Errorf("IRCListenAddr = %q, want %q", cfg.IRCListenAddr, want)
}
}
func TestIRCListenAddrEmptyTurnsListenerOff(t *testing.T) {
t.Setenv("IRC_LISTEN_ADDR", "")
cfg := newConfig(t)
if cfg.IRCListenAddr != "" {
t.Errorf("IRCListenAddr = %q, want empty", cfg.IRCListenAddr)
}
}
+8 -1
View File
@@ -87,7 +87,7 @@ func (database *Database) GetDB() *sql.DB {
func (database *Database) connect(ctx context.Context) error { func (database *Database) connect(ctx context.Context) error {
dbURL := database.params.Config.DBURL dbURL := database.params.Config.DBURL
if dbURL == "" { if dbURL == "" {
dbURL = config.DefaultDBURL dbURL = "file:///var/lib/neoirc/state.db?_journal_mode=WAL&_busy_timeout=5000"
} }
database.log.Info( database.log.Info(
@@ -116,6 +116,13 @@ func (database *Database) connect(ctx context.Context) error {
return fmt.Errorf("enable foreign keys: %w", err) return fmt.Errorf("enable foreign keys: %w", err)
} }
_, err = database.conn.ExecContext(
ctx, "PRAGMA busy_timeout = 5000",
)
if err != nil {
return fmt.Errorf("set busy timeout: %w", err)
}
return database.runMigrations(ctx) return database.runMigrations(ctx)
} }
-75
View File
@@ -1,75 +0,0 @@
package db_test
import (
"path/filepath"
"strings"
"testing"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
)
const wantBusyTimeoutMS = 5000
// TestDefaultDBURLSetsWALAndBusyTimeout opens a file database
// with the default URL's parameters the way the server does,
// and checks the journal mode and busy timeout it ends up with.
func TestDefaultDBURLSetsWALAndBusyTimeout(t *testing.T) {
t.Parallel()
_, params, _ := strings.Cut(config.DefaultDBURL, "?")
dbURL := "file://" + filepath.Join(t.TempDir(), "state.db") +
"?" + params
lifecycle := fxtest.NewLifecycle(t)
log, err := logger.New(lifecycle, logger.Params{
Globals: &globals.Globals{},
})
if err != nil {
t.Fatal(err)
}
database, err := db.New(lifecycle, db.Params{
Logger: log,
Config: &config.Config{DBURL: dbURL},
})
if err != nil {
t.Fatal(err)
}
lifecycle.RequireStart()
t.Cleanup(lifecycle.RequireStop)
var journalMode string
err = database.GetDB().QueryRowContext(
t.Context(), "PRAGMA journal_mode",
).Scan(&journalMode)
if err != nil {
t.Fatal(err)
}
if journalMode != "wal" {
t.Errorf("journal_mode = %q, want %q", journalMode, "wal")
}
var busyTimeout int
err = database.GetDB().QueryRowContext(
t.Context(), "PRAGMA busy_timeout",
).Scan(&busyTimeout)
if err != nil {
t.Fatal(err)
}
if busyTimeout != wantBusyTimeoutMS {
t.Errorf(
"busy_timeout = %d, want %d",
busyTimeout, wantBusyTimeoutMS,
)
}
}
-11
View File
@@ -17,14 +17,3 @@ func IsUniqueConstraintError(err error) bool {
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE
} }
// IsForeignKeyConstraintError reports whether err is a
// SQLite foreign-key constraint violation.
func IsForeignKeyConstraintError(err error) bool {
var sqliteErr *sqlite.Error
if !errors.As(err, &sqliteErr) {
return false
}
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY
}
+4 -2
View File
@@ -59,8 +59,10 @@ func (database *Database) Close() error {
return nil return nil
} }
// ExecForTest runs a raw statement, such as a trigger that // ExecForTest runs a raw statement against the test
// makes a write fail. // database. Tests use it to install SQLite triggers that
// force a specific write to fail, so that the atomicity of
// multi-statement helpers can be exercised.
func (database *Database) ExecForTest( func (database *Database) ExecForTest(
ctx context.Context, ctx context.Context,
query string, query string,
+253 -576
View File
File diff suppressed because it is too large Load Diff
+62 -229
View File
@@ -2,7 +2,6 @@ package db_test
import ( import (
"encoding/json" "encoding/json"
"slices"
"testing" "testing"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
@@ -412,7 +411,7 @@ func TestJoinAndPart(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
err = database.PartChannelAndDeleteIfEmpty(ctx, chID, sid) err = database.PartChannel(ctx, chID, sid)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -423,10 +422,7 @@ func TestJoinAndPart(t *testing.T) {
} }
} }
// TestPartChannelAndDeleteIfEmpty checks that a part keeps func TestDeleteChannelIfEmpty(t *testing.T) {
// a channel another session is still on and deletes the
// channel when its last member parts.
func TestPartChannelAndDeleteIfEmpty(t *testing.T) {
t.Parallel() t.Parallel()
database := setupTestDB(t) database := setupTestDB(t)
@@ -439,34 +435,22 @@ func TestPartChannelAndDeleteIfEmpty(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
nicks := []string{"temp1", "temp2"} sid, _, _, err := database.CreateSession(ctx, "temp", "", "", "")
sids := make([]int64, 0, len(nicks))
for _, nick := range nicks {
sid, _, _, createErr := database.CreateSession(ctx, nick, "", "", "")
if createErr != nil {
t.Fatal(createErr)
}
err = database.JoinChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
sids = append(sids, sid)
}
err = database.PartChannelAndDeleteIfEmpty(ctx, chID, sids[0])
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
_, err = database.GetChannelByName(ctx, "#empty") err = database.JoinChannel(ctx, chID, sid)
if err != nil { if err != nil {
t.Fatalf("channel deleted while a member is on it: %v", err) t.Fatal(err)
} }
err = database.PartChannelAndDeleteIfEmpty(ctx, chID, sids[1]) err = database.PartChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
err = database.DeleteChannelIfEmpty(ctx, chID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -635,7 +619,7 @@ func TestInsertMessage(t *testing.T) {
body := json.RawMessage(`["hello"]`) body := json.RawMessage(`["hello"]`)
dbID, msgUUID, err := database.InsertMessage( dbID, msgUUID, err := database.InsertMessage(
ctx, "PRIVMSG", "poller", "#test", 0, 0, nil, body, nil, ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -669,13 +653,13 @@ func TestPollMessages(t *testing.T) {
body := json.RawMessage(`["hello"]`) body := json.RawMessage(`["hello"]`)
dbID, _, err := database.InsertMessage( dbID, _, err := database.InsertMessage(
ctx, "PRIVMSG", "poller", "#test", 0, 0, nil, body, nil, ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
err = database.EnqueueToSession(ctx, sid, dbID, 0) err = database.EnqueueToSession(ctx, sid, dbID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -722,16 +706,11 @@ func TestGetHistory(t *testing.T) {
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
chID, err := database.GetOrCreateChannel(ctx, "#hist")
if err != nil {
t.Fatal(err)
}
const msgCount = 10 const msgCount = 10
for range msgCount { for range msgCount {
_, _, err = database.InsertMessage( _, _, err := database.InsertMessage(
ctx, "PRIVMSG", "user", "#hist", 0, chID, ctx, "PRIVMSG", "user", "#hist",
nil, json.RawMessage(`["msg"]`), nil, nil, json.RawMessage(`["msg"]`), nil,
) )
if err != nil { if err != nil {
@@ -742,7 +721,7 @@ func TestGetHistory(t *testing.T) {
const histLimit = 5 const histLimit = 5
msgs, err := database.GetHistory( msgs, err := database.GetHistory(
ctx, 0, chID, "", histLimit, ctx, "#hist", 0, histLimit,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -758,209 +737,44 @@ func TestGetHistory(t *testing.T) {
} }
} }
// TestGetHistoryReusedNames checks that history follows the func TestDeleteSession(t *testing.T) {
// session and the channel row, not the nick or the channel
// name: a session that takes a released nick, and a channel
// created again under a deleted one's name, start with none.
func TestGetHistoryReusedNames(t *testing.T) {
t.Parallel() t.Parallel()
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
historyLen := func(toSessionID, toChannelID int64) int { sid, _, _, err := database.CreateSession(
t.Helper() ctx, "deleteme", "", "", "",
msgs, err := database.GetHistory(
ctx, toSessionID, toChannelID, "", 0,
)
if err != nil {
t.Fatal(err)
}
return len(msgs)
}
oldSID, _, _, err := database.CreateSession(
ctx, "reused", "", "", "",
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
oldChID, err := database.GetOrCreateChannel(ctx, "#reused") chID, err := database.GetOrCreateChannel(
if err != nil { ctx, "#delchan",
t.Fatal(err)
}
err = database.JoinChannel(ctx, oldChID, oldSID)
if err != nil {
t.Fatal(err)
}
body := json.RawMessage(`["hi"]`)
_, _, err = database.InsertMessage(
ctx, "PRIVMSG", "sender", "reused", oldSID, 0,
nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
_, _, err = database.InsertMessage( err = database.JoinChannel(ctx, chID, sid)
ctx, "PRIVMSG", "sender", "#reused", 0, oldChID,
nil, body, nil,
)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if historyLen(oldSID, 0) != 1 || historyLen(0, oldChID) != 1 { err = database.DeleteSession(ctx, sid)
t.Fatal("expected one DM and one channel message")
}
// The session is the channel's only member, so quitting
// deletes the channel too.
_, err = database.QuitSession(ctx, oldSID, "reused", nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
newSID, _, _, err := database.CreateSession(
ctx, "reused", "", "", "",
)
if err != nil {
t.Fatal(err)
}
newChID, err := database.GetOrCreateChannel(ctx, "#reused")
if err != nil {
t.Fatal(err)
}
if historyLen(newSID, 0) != 0 || historyLen(0, newChID) != 0 {
t.Fatal("expected no history for the names' new holders")
}
}
// TestQuitSession checks that QuitSession deletes the
// session, keeps a channel another session is still on,
// deletes a channel it leaves with no members, and queues
// one QUIT to the other session; and that a second call for
// the same session changes nothing.
func TestQuitSession(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sid, sharedID, _ := createSessionWithChannels(
t, database, "deleteme", "#shared", "#alone",
)
peerSID, peerClientID, _, err := database.CreateSession(
ctx, "peer", "", "", "",
)
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, sharedID, peerSID)
if err != nil {
t.Fatal(err)
}
body := json.RawMessage(`["bye"]`)
peerIDs, err := database.QuitSession(ctx, sid, "deleteme", body)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(peerIDs, []int64{peerSID}) {
t.Fatalf("got peers %v, want [%d]", peerIDs, peerSID)
}
_, err = database.GetSessionByNick(ctx, "deleteme") _, err = database.GetSessionByNick(ctx, "deleteme")
if err == nil { if err == nil {
t.Fatal("session should be deleted") t.Fatal("session should be deleted")
} }
ids, _ := database.GetChannelMemberIDs(ctx, sharedID) ids, _ := database.GetChannelMemberIDs(ctx, chID)
if !slices.Equal(ids, []int64{peerSID}) { if len(ids) != 0 {
t.Fatalf("#shared members: got %v, want [%d]", ids, peerSID) t.Fatal("expected no members after deletion")
}
_, err = database.GetChannelByName(ctx, "#alone")
if err == nil {
t.Fatal("expected the emptied channel to be deleted")
}
checkOneQuitQueued(t, database, peerClientID, "deleteme")
peerIDs, err = database.QuitSession(ctx, sid, "deleteme", body)
if err != nil || len(peerIDs) != 0 {
t.Fatalf("second quit: got %v, %v; want none", peerIDs, err)
}
checkOneQuitQueued(t, database, peerClientID, "deleteme")
}
// checkOneQuitQueued fails the test unless the queue of the
// client clientID holds one message, a QUIT from nick.
func checkOneQuitQueued(
t *testing.T,
database *db.Database,
clientID int64,
nick string,
) {
t.Helper()
msgs, _, err := database.PollMessages(t.Context(), clientID, 0, 0)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 1 || msgs[0].Command != "QUIT" || msgs[0].From != nick {
t.Fatalf("queue: got %v, want one QUIT from %s", msgs, nick)
}
}
// TestLogoutClientFailureKeepsClient checks that when the
// session of a logout's last client cannot be deleted,
// LogoutClient fails and the client stays logged in.
func TestLogoutClientFailureKeepsClient(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sid, clientID, token, err := database.CreateSession(
ctx, "stuck", "", "", "",
)
if err != nil {
t.Fatal(err)
}
_, err = database.GetDB().ExecContext(ctx,
`CREATE TRIGGER keep_sessions
BEFORE DELETE ON sessions
BEGIN
SELECT RAISE(ABORT, 'sessions are kept');
END`)
if err != nil {
t.Fatal(err)
}
_, err = database.LogoutClient(ctx, sid, clientID, "stuck", nil)
if err == nil {
t.Fatal("logout succeeded although the session was kept")
}
_, _, nick, err := database.GetSessionByToken(ctx, token)
if err != nil || nick != "stuck" {
t.Fatalf("client gone after a failed logout: %q, %v", nick, err)
} }
} }
@@ -1049,7 +863,7 @@ func TestEnqueueToClient(t *testing.T) {
body := json.RawMessage(`["test"]`) body := json.RawMessage(`["test"]`)
dbID, _, err := database.InsertMessage( dbID, _, err := database.InsertMessage(
ctx, "PRIVMSG", "sender", "#ch", 0, 0, nil, body, nil, ctx, "PRIVMSG", "sender", "#ch", nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -1439,6 +1253,17 @@ func TestChannelInviteCRUD(t *testing.T) {
if !has { if !has {
t.Fatal("should have invite") t.Fatal("should have invite")
} }
// Clear invite.
err = database.ClearChannelInvite(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
has, _ = database.HasChannelInvite(ctx, chID, sid)
if has {
t.Fatal("invite should be cleared")
}
} }
func TestChannelSecret(t *testing.T) { func TestChannelSecret(t *testing.T) {
@@ -1667,9 +1492,13 @@ func TestChannelUserLimit(t *testing.T) {
} }
} }
// TestSetSessionUserModesIsAtomic makes the is_oper write // TestSetSessionUserModesIsAtomic proves that a multi-mode
// fail after the is_wallops write has run, and checks that // change is all-or-nothing. A trigger makes the is_oper
// the wallops change was rolled back. // UPDATE fail after the is_wallops UPDATE has already run,
// which is exactly the "+w-o" partial-failure the previous
// implementation exhibited: it issued the two UPDATEs
// independently, so +w persisted while the caller reported
// total failure.
func TestSetSessionUserModesIsAtomic(t *testing.T) { func TestSetSessionUserModesIsAtomic(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1711,12 +1540,16 @@ func TestSetSessionUserModesIsAtomic(t *testing.T) {
} }
if gotWallops { if gotWallops {
t.Error("wallops was stored although the change failed") t.Error(
"wallops persisted despite the transaction " +
"failing; the apply stage is not atomic",
)
} }
} }
// TestSetSessionUserModesAppliesBoth checks that both flags // TestSetSessionUserModesAppliesBoth is the success-path
// are written, and that a nil flag is left as it is. // counterpart: when nothing fails, both flags are written,
// and a nil pointer leaves that flag untouched.
func TestSetSessionUserModesAppliesBoth(t *testing.T) { func TestSetSessionUserModesAppliesBoth(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1730,18 +1563,18 @@ func TestSetSessionUserModesAppliesBoth(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
err = database.SetSessionOper(ctx, sessionID, true) if err := database.SetSessionOper(
if err != nil { ctx, sessionID, true,
); err != nil {
t.Fatal(err) t.Fatal(err)
} }
wallops := true wallops := true
oper := false oper := false
err = database.SetSessionUserModes( if err := database.SetSessionUserModes(
ctx, sessionID, &wallops, &oper, ctx, sessionID, &wallops, &oper,
) ); err != nil {
if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -1764,10 +1597,10 @@ func TestSetSessionUserModesAppliesBoth(t *testing.T) {
) )
} }
err = database.SetSessionUserModes( // A nil pointer must leave the stored value alone.
if err := database.SetSessionUserModes(
ctx, sessionID, nil, nil, ctx, sessionID, nil, nil,
) ); err != nil {
if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -1779,6 +1612,6 @@ func TestSetSessionUserModesAppliesBoth(t *testing.T) {
} }
if !gotWallops { if !gotWallops {
t.Error("a nil wallops flag cleared wallops") t.Error("nil pointers must not clear wallops")
} }
} }
+2 -8
View File
@@ -85,25 +85,19 @@ CREATE TABLE IF NOT EXISTS channel_members (
UNIQUE(channel_id, session_id) UNIQUE(channel_id, session_id)
); );
-- Messages: IRC envelope format. to_session_id is the session a DM -- Messages: IRC envelope format
-- or INVITE was sent to, to_channel_id the channel a channel message
-- was sent to. History is selected by these ids because nicks and
-- channel names are reused once released, and ids are not.
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE, uuid TEXT NOT NULL UNIQUE,
command TEXT NOT NULL DEFAULT 'PRIVMSG', command TEXT NOT NULL DEFAULT 'PRIVMSG',
msg_from TEXT NOT NULL DEFAULT '', msg_from TEXT NOT NULL DEFAULT '',
msg_to TEXT NOT NULL DEFAULT '', msg_to TEXT NOT NULL DEFAULT '',
to_session_id INTEGER REFERENCES sessions(id) ON DELETE SET NULL,
to_channel_id INTEGER REFERENCES channels(id) ON DELETE SET NULL,
params TEXT NOT NULL DEFAULT '[]', params TEXT NOT NULL DEFAULT '[]',
body TEXT NOT NULL DEFAULT '[]', body TEXT NOT NULL DEFAULT '[]',
meta TEXT NOT NULL DEFAULT '{}', meta TEXT NOT NULL DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_messages_to_session ON messages(to_session_id, id); CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(msg_to, id);
CREATE INDEX IF NOT EXISTS idx_messages_to_channel ON messages(to_channel_id, id);
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at); CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
-- Spent hashcash tokens for replay prevention (1-year TTL) -- Spent hashcash tokens for replay prevention (1-year TTL)
+442 -167
View File
@@ -2,7 +2,6 @@ package handlers
import ( import (
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -15,10 +14,24 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc" "sneak.berlin/go/neoirc/pkg/irc"
) )
var (
errHashcashRequired = errors.New("hashcash required")
errHashcashReused = errors.New("hashcash reused")
)
var validNickRe = regexp.MustCompile(
`^[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}$`,
) )
@@ -199,7 +212,7 @@ func (hdlr *Handlers) handleCreateSession(
return return
} }
if !hdlr.checkHashcash( if !hdlr.validateHashcash(
writer, request, payload.Hashcash, writer, request, payload.Hashcash,
) { ) {
return return
@@ -207,7 +220,7 @@ func (hdlr *Handlers) handleCreateSession(
payload.Nick = strings.TrimSpace(payload.Nick) payload.Nick = strings.TrimSpace(payload.Nick)
if !service.ValidNick(payload.Nick) { if !validNickRe.MatchString(payload.Nick) {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
"invalid nick format", "invalid nick format",
@@ -232,15 +245,14 @@ func (hdlr *Handlers) handleCreateSession(
} }
hdlr.executeCreateSession( hdlr.executeCreateSession(
writer, request, writer, request, payload.Nick, username,
payload.Nick, username, payload.Hashcash,
) )
} }
func (hdlr *Handlers) executeCreateSession( func (hdlr *Handlers) executeCreateSession(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
nick, username, stamp string, nick, username string,
) { ) {
remoteIP := clientIP(request) remoteIP := clientIP(request)
@@ -248,17 +260,12 @@ func (hdlr *Handlers) executeCreateSession(
request.Context(), remoteIP, request.Context(), remoteIP,
) )
if !hdlr.claimHashcash(writer, request, stamp) {
return
}
sessionID, clientID, token, err := sessionID, clientID, token, err :=
hdlr.params.Database.CreateSession( hdlr.params.Database.CreateSession(
request.Context(), request.Context(),
nick, username, hostname, remoteIP, nick, username, hostname, remoteIP,
) )
if err != nil { if err != nil {
hdlr.hashcashVal.Release(stamp)
hdlr.handleCreateSessionError( hdlr.handleCreateSessionError(
writer, request, err, writer, request, err,
) )
@@ -279,10 +286,10 @@ func (hdlr *Handlers) executeCreateSession(
}, http.StatusCreated) }, http.StatusCreated)
} }
// checkHashcash checks a hashcash stamp if required, // validateHashcash validates a hashcash stamp if required.
// without spending it. Returns false if the check failed // Returns false if validation failed and a response was
// and a response was already sent. // already sent.
func (hdlr *Handlers) checkHashcash( func (hdlr *Handlers) validateHashcash(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
stamp string, stamp string,
@@ -301,31 +308,7 @@ func (hdlr *Handlers) checkHashcash(
return false return false
} }
err := hdlr.hashcashVal.Check( err := hdlr.hashcashVal.Validate(
stamp, hdlr.params.Config.HashcashBits,
)
if err != nil {
hdlr.respondError(
writer, request,
"invalid hashcash stamp: "+err.Error(),
http.StatusPaymentRequired,
)
return false
}
return true
}
// claimHashcash spends a stamp that checkHashcash accepted.
// Returns false if Claim refused the stamp, normally because
// it was already spent, and a response was already sent.
func (hdlr *Handlers) claimHashcash(
writer http.ResponseWriter,
request *http.Request,
stamp string,
) bool {
err := hdlr.hashcashVal.Claim(
stamp, hdlr.params.Config.HashcashBits, stamp, hdlr.params.Config.HashcashBits,
) )
if err != nil { if err != nil {
@@ -596,7 +579,7 @@ func (hdlr *Handlers) enqueueNumeric(
} }
dbID, _, insertErr := hdlr.params.Database.InsertMessage( dbID, _, insertErr := hdlr.params.Database.InsertMessage(
ctx, command, hdlr.serverName(), nick, 0, 0, ctx, command, hdlr.serverName(), nick,
paramsJSON, json.RawMessage(body), nil, paramsJSON, json.RawMessage(body), nil,
) )
if insertErr != nil { if insertErr != nil {
@@ -682,7 +665,7 @@ func (hdlr *Handlers) initChannelState(
dbID, _, insErr := hdlr.params.Database. dbID, _, insErr := hdlr.params.Database.
InsertMessage( InsertMessage(
ctx, "JOIN", nick, chanInfo.Name, ctx, "JOIN", nick, chanInfo.Name,
0, chanInfo.ID, nil, nil, nil, nil, nil, nil,
) )
if insErr != nil { if insErr != nil {
hdlr.log.Error( hdlr.log.Error(
@@ -813,14 +796,7 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc {
timeout = maxLongPollTimeout timeout = maxLongPollTimeout
} }
// Register with the broker before reading the queue. msgs, lastQID, err := hdlr.params.Database.PollMessages(
// Notify wakes only the channels registered at that
// moment, so a message queued after the read below
// must find this one there already.
waitCh := hdlr.broker.Wait(sessionID)
defer hdlr.broker.Remove(sessionID, waitCh)
msgs, lastQID, err := hdlr.pollMessages(
request.Context(), clientID, request.Context(), clientID,
afterID, pollMessageLimit, afterID, pollMessageLimit,
) )
@@ -848,22 +824,19 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc {
hdlr.longPoll( hdlr.longPoll(
writer, request, writer, request,
waitCh, clientID, afterID, timeout, sessionID, clientID, afterID, timeout,
) )
} }
} }
// longPoll waits up to timeout seconds for the broker to
// signal waitCh, then answers with the messages queued for
// clientID after afterID. It answers nothing if the client
// goes away first.
func (hdlr *Handlers) longPoll( func (hdlr *Handlers) longPoll(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
waitCh <-chan struct{}, sessionID, clientID, afterID int64,
clientID, afterID int64,
timeout int, timeout int,
) { ) {
waitCh := hdlr.broker.Wait(sessionID)
timer := time.NewTimer( timer := time.NewTimer(
time.Duration(timeout) * time.Second, time.Duration(timeout) * time.Second,
) )
@@ -874,10 +847,14 @@ func (hdlr *Handlers) longPoll(
case <-waitCh: case <-waitCh:
case <-timer.C: case <-timer.C:
case <-request.Context().Done(): case <-request.Context().Done():
hdlr.broker.Remove(sessionID, waitCh)
return return
} }
msgs, lastQID, err := hdlr.pollMessages( hdlr.broker.Remove(sessionID, waitCh)
msgs, lastQID, err := hdlr.params.Database.PollMessages(
request.Context(), clientID, request.Context(), clientID,
afterID, pollMessageLimit, afterID, pollMessageLimit,
) )
@@ -985,10 +962,12 @@ func (hdlr *Handlers) dispatchCommand(
bodyLines func() []string, bodyLines func() []string,
) { ) {
switch command { switch command {
case irc.CmdAway: case irc.CmdAway, irc.CmdNick,
hdlr.handleAway( irc.CmdPass, irc.CmdInvite:
hdlr.dispatchBodyOnlyCommand(
writer, request, writer, request,
sessionID, clientID, nick, bodyLines, sessionID, clientID, nick,
command, bodyLines,
) )
case irc.CmdPrivmsg, irc.CmdNotice: case irc.CmdPrivmsg, irc.CmdNotice:
hdlr.handlePrivmsg( hdlr.handlePrivmsg(
@@ -1007,27 +986,12 @@ func (hdlr *Handlers) dispatchCommand(
writer, request, writer, request,
sessionID, clientID, nick, target, body, sessionID, clientID, nick, target, body,
) )
case irc.CmdNick:
hdlr.handleNick(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdPass:
hdlr.handlePass(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdTopic: case irc.CmdTopic:
hdlr.handleTopic( hdlr.handleTopic(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
target, body, bodyLines, target, body, bodyLines,
) )
case irc.CmdInvite:
hdlr.handleInvite(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdKick: case irc.CmdKick:
hdlr.handleKick( hdlr.handleKick(
writer, request, writer, request,
@@ -1041,9 +1005,12 @@ func (hdlr *Handlers) dispatchCommand(
case irc.CmdOper, irc.CmdKill, irc.CmdWallops: case irc.CmdOper, irc.CmdKill, irc.CmdWallops:
hdlr.dispatchOperCommand( hdlr.dispatchOperCommand(
writer, request, writer, request,
sessionID, clientID, nick, command, bodyLines, sessionID, clientID, nick,
command, bodyLines,
) )
case irc.CmdMotd, irc.CmdPing: case irc.CmdMotd, irc.CmdPing,
irc.CmdVersion, irc.CmdAdmin,
irc.CmdInfo, irc.CmdTime:
hdlr.dispatchInfoCommand( hdlr.dispatchInfoCommand(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
@@ -1103,22 +1070,6 @@ func (hdlr *Handlers) dispatchQueryCommand(
writer, request, writer, request,
sessionID, clientID, nick, bodyLines, sessionID, clientID, nick, bodyLines,
) )
case irc.CmdVersion:
hdlr.handleVersion(
writer, request, sessionID, clientID, nick,
)
case irc.CmdAdmin:
hdlr.handleAdmin(
writer, request, sessionID, clientID, nick,
)
case irc.CmdInfo:
hdlr.handleInfo(
writer, request, sessionID, clientID, nick,
)
case irc.CmdTime:
hdlr.handleTime(
writer, request, sessionID, clientID, nick,
)
default: default:
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
request.Context(), clientID, request.Context(), clientID,
@@ -1257,11 +1208,26 @@ func (hdlr *Handlers) handleChannelMsg(
) { ) {
ctx := request.Context() ctx := request.Context()
// Delegate validation + fan-out to service layer. The // Hashcash validation is HTTP-specific; needs chID.
// fan-out includes the sender's own session, so all isNotice := command == irc.CmdNotice
// their clients see the message in long-poll responses.
uuid, err := hdlr.svc.SendChannelMessage( if !isNotice {
ctx, sessionID, 0, nick, chID, chErr := hdlr.params.Database.
GetChannelByName(ctx, target)
if chErr == nil {
hashcashErr := hdlr.validateChannelHashcash(
request, clientID, sessionID,
writer, nick, target, body, meta, chID,
)
if hashcashErr != nil {
return
}
}
}
// Delegate validation + fan-out to service layer.
dbID, uuid, err := hdlr.svc.SendChannelMessage(
ctx, sessionID, nick,
command, target, body, meta, command, target, body, meta,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1271,11 +1237,172 @@ func (hdlr *Handlers) handleChannelMsg(
return return
} }
// HTTP echo: enqueue to sender so all their clients
// see the message in long-poll responses.
_ = hdlr.params.Database.EnqueueToSession(
ctx, sessionID, dbID,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"id": uuid, statusKey: "sent"}, map[string]string{"id": uuid, statusKey: "sent"},
http.StatusOK) http.StatusOK)
} }
// validateChannelHashcash checks whether the channel
// requires hashcash proof-of-work for messages and
// validates the stamp from the message meta field.
// Returns nil on success or if the channel has no
// hashcash requirement. On failure, it sends the
// appropriate IRC error and returns a non-nil error.
func (hdlr *Handlers) validateChannelHashcash(
request *http.Request,
clientID, sessionID int64,
writer http.ResponseWriter,
nick, target string,
body json.RawMessage,
meta json.RawMessage,
chID int64,
) error {
ctx := request.Context()
bits, bitsErr := hdlr.params.Database.GetChannelHashcashBits(
ctx, chID,
)
if bitsErr != nil {
hdlr.log.Error(
"get channel hashcash bits", "error", bitsErr,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return fmt.Errorf("channel hashcash bits: %w", bitsErr)
}
if bits <= 0 {
return nil
}
stamp := hdlr.extractHashcashFromMeta(meta)
if stamp == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCannotSendToChan, nick, []string{target},
"Channel requires hashcash proof-of-work",
)
return errHashcashRequired
}
return hdlr.verifyChannelStamp(
request, writer,
clientID, sessionID,
nick, target, body, stamp, bits,
)
}
// verifyChannelStamp validates a channel hashcash stamp
// and checks for replay attacks.
func (hdlr *Handlers) verifyChannelStamp(
request *http.Request,
writer http.ResponseWriter,
clientID, sessionID int64,
nick, target string,
body json.RawMessage,
stamp string,
bits int,
) error {
ctx := request.Context()
bodyHashStr := hashcash.BodyHash(body)
valErr := hdlr.channelHashcash.ValidateStamp(
stamp, bits, target, bodyHashStr,
)
if valErr != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCannotSendToChan, nick, []string{target},
"Invalid hashcash: "+valErr.Error(),
)
return fmt.Errorf("channel hashcash: %w", valErr)
}
stampKey := hashcash.StampHash(stamp)
spent, spentErr := hdlr.params.Database.IsHashcashSpent(
ctx, stampKey,
)
if spentErr != nil {
hdlr.log.Error(
"check spent hashcash", "error", spentErr,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return fmt.Errorf("check spent hashcash: %w", spentErr)
}
if spent {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCannotSendToChan, nick, []string{target},
"Hashcash stamp already used",
)
return errHashcashReused
}
recordErr := hdlr.params.Database.RecordSpentHashcash(
ctx, stampKey,
)
if recordErr != nil {
hdlr.log.Error(
"record spent hashcash", "error", recordErr,
)
}
return nil
}
// extractHashcashFromMeta parses the meta JSON and
// returns the hashcash stamp string, or empty string
// if not present.
func (hdlr *Handlers) extractHashcashFromMeta(
meta json.RawMessage,
) string {
if len(meta) == 0 {
return ""
}
var metaMap map[string]json.RawMessage
err := json.Unmarshal(meta, &metaMap)
if err != nil {
return ""
}
raw, ok := metaMap["hashcash"]
if !ok {
return ""
}
var stamp string
err = json.Unmarshal(raw, &stamp)
if err != nil {
return ""
}
return stamp
}
func (hdlr *Handlers) handleDirectMsg( func (hdlr *Handlers) handleDirectMsg(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -1285,7 +1412,7 @@ func (hdlr *Handlers) handleDirectMsg(
meta json.RawMessage, meta json.RawMessage,
) { ) {
result, err := hdlr.svc.SendDirectMessage( result, err := hdlr.svc.SendDirectMessage(
request.Context(), sessionID, 0, nick, request.Context(), sessionID, nick,
command, target, body, meta, command, target, body, meta,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1335,6 +1462,16 @@ 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
@@ -1356,7 +1493,7 @@ func (hdlr *Handlers) executeJoin(
nick, channel, suppliedKey string, nick, channel, suppliedKey string,
) { ) {
result, err := hdlr.svc.JoinChannel( result, err := hdlr.svc.JoinChannel(
request.Context(), sessionID, 0, nick, request.Context(), sessionID, nick,
channel, suppliedKey, channel, suppliedKey,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1540,7 +1677,7 @@ func (hdlr *Handlers) handlePart(
} }
err := hdlr.svc.PartChannel( err := hdlr.svc.PartChannel(
request.Context(), sessionID, 0, request.Context(), sessionID,
nick, channel, reason, nick, channel, reason,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1578,7 +1715,7 @@ func (hdlr *Handlers) handleNick(
newNick := strings.TrimSpace(lines[0]) newNick := strings.TrimSpace(lines[0])
if !service.ValidNick(newNick) { if !validNickRe.MatchString(newNick) {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, clientID, sessionID, writer, request, clientID, sessionID,
irc.ErrErroneusNickname, nick, []string{newNick}, irc.ErrErroneusNickname, nick, []string{newNick},
@@ -1611,7 +1748,7 @@ func (hdlr *Handlers) executeNickChange(
nick, newNick string, nick, newNick string,
) { ) {
err := hdlr.svc.ChangeNick( err := hdlr.svc.ChangeNick(
request.Context(), sessionID, 0, nick, newNick, request.Context(), sessionID, nick, newNick,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
writer, request, writer, request,
@@ -1666,7 +1803,7 @@ func (hdlr *Handlers) handleTopic(
topic := strings.Join(lines, " ") topic := strings.Join(lines, " ")
err := hdlr.svc.SetTopic( err := hdlr.svc.SetTopic(
request.Context(), sessionID, 0, request.Context(), sessionID,
nick, channel, topic, nick, channel, topic,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1726,7 +1863,8 @@ func (hdlr *Handlers) deliverSetTopicNumerics(
} }
// dispatchInfoCommand handles informational IRC commands // dispatchInfoCommand handles informational IRC commands
// that produce server-side numerics (MOTD, PING). // that produce server-side numerics (MOTD, PING,
// VERSION, ADMIN, INFO, TIME).
func (hdlr *Handlers) dispatchInfoCommand( func (hdlr *Handlers) dispatchInfoCommand(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -1752,6 +1890,34 @@ func (hdlr *Handlers) dispatchInfoCommand(
}, },
http.StatusOK) http.StatusOK)
return
case irc.CmdVersion:
hdlr.handleVersion(
writer, request,
sessionID, clientID, nick,
)
return
case irc.CmdAdmin:
hdlr.handleAdmin(
writer, request,
sessionID, clientID, nick,
)
return
case irc.CmdInfo:
hdlr.handleInfo(
writer, request,
sessionID, clientID, nick,
)
return
case irc.CmdTime:
hdlr.handleTime(
writer, request,
sessionID, clientID, nick,
)
return return
} }
@@ -1788,9 +1954,8 @@ func (hdlr *Handlers) handleQuit(
http.StatusOK) http.StatusOK)
} }
// handleMode handles the MODE command: a target that starts // handleMode handles the MODE command for channels and
// with # is a channel, handled by handleChannelMode, and any // users. Currently supports query-only (no mode changes).
// other target a nick, handled by handleUserMode.
func (hdlr *Handlers) handleMode( func (hdlr *Handlers) handleMode(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -2152,7 +2317,7 @@ func (hdlr *Handlers) applyUserMode(
modeText := modeArgs[0] + " " + targetNick modeText := modeArgs[0] + " " + targetNick
hdlr.svc.BroadcastMode( hdlr.svc.BroadcastMode(
request.Context(), 0, nick, channel, chID, request.Context(), nick, channel, chID,
modeText, modeText,
) )
@@ -2198,7 +2363,7 @@ func (hdlr *Handlers) setChannelFlag(
} }
hdlr.svc.BroadcastMode( hdlr.svc.BroadcastMode(
request.Context(), 0, nick, channel, chID, request.Context(), nick, channel, chID,
modeStr, modeStr,
) )
@@ -2435,7 +2600,7 @@ func (hdlr *Handlers) executeBanChange(
) )
if mErr == nil { if mErr == nil {
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdMode, nick, channel, 0, chID, request, irc.CmdMode, nick, channel,
json.RawMessage(modeBody), memberIDs, json.RawMessage(modeBody), memberIDs,
) )
} }
@@ -2553,7 +2718,7 @@ func (hdlr *Handlers) setChannelKeyMode(
modeBody, mErr := json.Marshal([]string{"+k", key}) modeBody, mErr := json.Marshal([]string{"+k", key})
if mErr == nil { if mErr == nil {
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdMode, nick, channel, 0, chID, request, irc.CmdMode, nick, channel,
json.RawMessage(modeBody), memberIDs, json.RawMessage(modeBody), memberIDs,
) )
} }
@@ -2602,7 +2767,7 @@ func (hdlr *Handlers) clearChannelKeyMode(
modeBody, mErr := json.Marshal([]string{"-k", "*"}) modeBody, mErr := json.Marshal([]string{"-k", "*"})
if mErr == nil { if mErr == nil {
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdMode, nick, channel, 0, chID, request, irc.CmdMode, nick, channel,
json.RawMessage(modeBody), memberIDs, json.RawMessage(modeBody), memberIDs,
) )
} }
@@ -2677,7 +2842,7 @@ func (hdlr *Handlers) setChannelLimitMode(
) )
if mErr == nil { if mErr == nil {
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdMode, nick, channel, 0, chID, request, irc.CmdMode, nick, channel,
json.RawMessage(modeBody), memberIDs, json.RawMessage(modeBody), memberIDs,
) )
} }
@@ -2727,7 +2892,7 @@ func (hdlr *Handlers) clearChannelLimitMode(
modeBody, mErr := json.Marshal([]string{"-l"}) modeBody, mErr := json.Marshal([]string{"-l"})
if mErr == nil { if mErr == nil {
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdMode, nick, channel, 0, chID, request, irc.CmdMode, nick, channel,
json.RawMessage(modeBody), memberIDs, json.RawMessage(modeBody), memberIDs,
) )
} }
@@ -2764,15 +2929,123 @@ func (hdlr *Handlers) handleInvite(
channel = "#" + channel channel = "#" + channel
} }
chID, targetSID, ok := hdlr.validateInvite(
writer, request,
sessionID, clientID, nick,
targetNick, channel,
)
if !ok {
return
}
hdlr.executeInvite(
writer, request,
sessionID, clientID, nick,
targetNick, channel, chID, targetSID,
)
}
// validateInvite checks channel, membership, permissions,
// and target for an INVITE command.
func (hdlr *Handlers) validateInvite(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, targetNick, channel string,
) (int64, int64, bool) {
ctx := request.Context() ctx := request.Context()
targetSID, err := hdlr.svc.InviteUser( chID, err := hdlr.params.Database.GetChannelByName(
ctx, sessionID, nick, targetNick, channel, ctx, channel,
) )
if hdlr.handleServiceError( if err != nil {
writer, request, hdlr.respondIRCError(
clientID, sessionID, nick, err, writer, request, clientID, sessionID,
) { irc.ErrNoSuchChannel, nick,
[]string{channel}, "No such channel",
)
return 0, 0, false
}
isMember, memErr := hdlr.params.Database.IsChannelMember(
ctx, chID, sessionID,
)
if memErr != nil || !isMember {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNotOnChannel, nick,
[]string{channel},
"You're not on that channel",
)
return 0, 0, false
}
isInviteOnly, ioErr := hdlr.params.Database.
IsChannelInviteOnly(ctx, chID)
if ioErr == nil && isInviteOnly {
if !hdlr.requireChannelOp(
writer, request,
sessionID, clientID, nick, channel, chID,
) {
return 0, 0, false
}
}
targetSID, nickErr := hdlr.params.Database.
GetSessionByNick(ctx, targetNick)
if nickErr != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoSuchNick, nick,
[]string{targetNick},
"No such nick/channel",
)
return 0, 0, false
}
alreadyIn, aiErr := hdlr.params.Database.
IsChannelMember(ctx, chID, targetSID)
if aiErr == nil && alreadyIn {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUserOnChannel, nick,
[]string{targetNick, channel},
"is already on channel",
)
return 0, 0, false
}
return chID, targetSID, true
}
// executeInvite records the invite and sends
// notifications.
func (hdlr *Handlers) executeInvite(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, targetNick, channel string,
chID, targetSID int64,
) {
ctx := request.Context()
invErr := hdlr.params.Database.AddChannelInvite(
ctx, chID, targetSID, nick,
)
if invErr != nil {
hdlr.log.Error(
"add invite failed", "error", invErr,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return return
} }
@@ -2786,7 +3059,7 @@ func (hdlr *Handlers) handleInvite(
if mErr == nil { if mErr == nil {
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdInvite, nick, targetNick, request, irc.CmdInvite, nick, targetNick,
targetSID, 0, json.RawMessage(invBody), json.RawMessage(invBody),
[]int64{targetSID}, []int64{targetSID},
) )
} }
@@ -3281,13 +3554,16 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
return return
} }
toSessionID, toChannelID, ok := hdlr.canAccessHistory( if !hdlr.canAccessHistory(
writer, request, sessionID, nick, target, writer, request, sessionID, nick, target,
) ) {
if !ok {
return return
} }
beforeID, _ := strconv.ParseInt(
request.URL.Query().Get("before"), 10, 64,
)
limit, _ := strconv.Atoi( limit, _ := strconv.Atoi(
request.URL.Query().Get("limit"), request.URL.Query().Get("limit"),
) )
@@ -3296,19 +3572,8 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
} }
msgs, err := hdlr.params.Database.GetHistory( msgs, err := hdlr.params.Database.GetHistory(
request.Context(), toSessionID, toChannelID, request.Context(), target, beforeID, limit,
request.URL.Query().Get("before"), limit,
) )
if errors.Is(err, sql.ErrNoRows) {
hdlr.respondError(
writer, request,
"unknown before id",
http.StatusBadRequest,
)
return
}
if err != nil { if err != nil {
hdlr.log.Error( hdlr.log.Error(
"get history failed", "error", err, "get history failed", "error", err,
@@ -3329,23 +3594,17 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
} }
// canAccessHistory verifies the user can read history // canAccessHistory verifies the user can read history
// for the given target (channel or DM participant) and // for the given target (channel or DM participant).
// returns the session id and channel id to pass to
// GetHistory: the user's own session for its nick, or the
// channel's id, with 0 for the other. On false it has
// written the error response.
func (hdlr *Handlers) canAccessHistory( func (hdlr *Handlers) canAccessHistory(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID int64,
nick, target string, nick, target string,
) (int64, int64, bool) { ) bool {
if strings.HasPrefix(target, "#") { if strings.HasPrefix(target, "#") {
chID, ok := hdlr.canAccessChannelHistory( return hdlr.canAccessChannelHistory(
writer, request, sessionID, target, writer, request, sessionID, target,
) )
return 0, chID, ok
} }
// DM history: only allow if the target is the // DM history: only allow if the target is the
@@ -3357,20 +3616,18 @@ func (hdlr *Handlers) canAccessHistory(
http.StatusForbidden, http.StatusForbidden,
) )
return 0, 0, false return false
} }
return sessionID, 0, true return true
} }
// canAccessChannelHistory returns the id of the channel
// named target if the user is a member of it.
func (hdlr *Handlers) canAccessChannelHistory( func (hdlr *Handlers) canAccessChannelHistory(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID int64,
target string, target string,
) (int64, bool) { ) bool {
chID, err := hdlr.params.Database.GetChannelByName( chID, err := hdlr.params.Database.GetChannelByName(
request.Context(), target, request.Context(), target,
) )
@@ -3381,7 +3638,7 @@ func (hdlr *Handlers) canAccessChannelHistory(
http.StatusNotFound, http.StatusNotFound,
) )
return 0, false return false
} }
isMember, err := hdlr.params.Database.IsChannelMember( isMember, err := hdlr.params.Database.IsChannelMember(
@@ -3397,7 +3654,7 @@ func (hdlr *Handlers) canAccessChannelHistory(
http.StatusInternalServerError, http.StatusInternalServerError,
) )
return 0, false return false
} }
if !isMember { if !isMember {
@@ -3407,10 +3664,10 @@ func (hdlr *Handlers) canAccessChannelHistory(
http.StatusForbidden, http.StatusForbidden,
) )
return 0, false return false
} }
return chID, true return true
} }
// HandleLogout deletes the authenticated client's token // HandleLogout deletes the authenticated client's token
@@ -3426,11 +3683,15 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
return return
} }
err := hdlr.svc.Logout( ctx := request.Context()
request.Context(), sessionID, clientID, nick,
err := hdlr.params.Database.DeleteClient(
ctx, clientID,
) )
if err != nil { if err != nil {
hdlr.log.Error("logout failed", "error", err) hdlr.log.Error(
"delete client failed", "error", err,
)
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
"internal error", "internal error",
@@ -3440,6 +3701,23 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
return return
} }
// If no clients remain, clean up the user fully:
// part all channels (notifying members) and
// delete the session.
remaining, err := hdlr.params.Database.
ClientCountForSession(ctx, sessionID)
if err != nil {
hdlr.log.Error(
"client count check failed", "error", err,
)
}
if remaining == 0 {
hdlr.cleanupUser(
ctx, sessionID, nick,
)
}
hdlr.clearAuthCookie(writer) hdlr.clearAuthCookie(writer)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
@@ -3645,7 +3923,7 @@ func (hdlr *Handlers) handleKick(
} }
err := hdlr.svc.KickUser( err := hdlr.svc.KickUser(
request.Context(), sessionID, 0, nick, request.Context(), sessionID, nick,
channel, targetNick, reason, channel, targetNick, reason,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -3698,19 +3976,17 @@ func (hdlr *Handlers) deliverWhoisIdle(
} }
// fanOut inserts a message and enqueues it to all given // fanOut inserts a message and enqueues it to all given
// sessions. toSessionID and toChannelID are the recipient // sessions.
// InsertMessage records for history, 0 when there is none.
func (hdlr *Handlers) fanOut( func (hdlr *Handlers) fanOut(
request *http.Request, request *http.Request,
command, from, target string, command, from, target string,
toSessionID, toChannelID int64,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage, meta json.RawMessage,
sessionIDs []int64, sessionIDs []int64,
) (string, error) { ) (string, error) {
dbID, msgUUID, err := hdlr.params.Database.InsertMessage( dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
request.Context(), command, from, target, request.Context(), command, from, target,
toSessionID, toChannelID, nil, body, meta, nil, body, meta,
) )
if err != nil { if err != nil {
return "", fmt.Errorf("insert message: %w", err) return "", fmt.Errorf("insert message: %w", err)
@@ -3718,7 +3994,7 @@ func (hdlr *Handlers) fanOut(
for _, sid := range sessionIDs { for _, sid := range sessionIDs {
enqErr := hdlr.params.Database.EnqueueToSession( enqErr := hdlr.params.Database.EnqueueToSession(
request.Context(), sid, dbID, 0, request.Context(), sid, dbID,
) )
if enqErr != nil { if enqErr != nil {
hdlr.log.Error("enqueue failed", hdlr.log.Error("enqueue failed",
@@ -3735,13 +4011,12 @@ func (hdlr *Handlers) fanOut(
func (hdlr *Handlers) fanOutSilent( func (hdlr *Handlers) fanOutSilent(
request *http.Request, request *http.Request,
command, from, target string, command, from, target string,
toSessionID, toChannelID int64,
body json.RawMessage, body json.RawMessage,
sessionIDs []int64, sessionIDs []int64,
) error { ) error {
_, err := hdlr.fanOut( _, err := hdlr.fanOut(
request, command, from, target, request, command, from, target,
toSessionID, toChannelID, body, nil, sessionIDs, body, nil, sessionIDs,
) )
return err return err
File diff suppressed because it is too large Load Diff
+3 -9
View File
@@ -8,12 +8,7 @@ import (
"sneak.berlin/go/neoirc/pkg/irc" "sneak.berlin/go/neoirc/pkg/irc"
) )
// A session password is 8 to 72 bytes; bcrypt cannot hash const minPasswordLength = 8
// a longer one.
const (
minPasswordLength = 8
maxPasswordLength = 72
)
// HandleLogin authenticates a user with nick and password. // HandleLogin authenticates a user with nick and password.
func (hdlr *Handlers) HandleLogin() http.HandlerFunc { func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
@@ -154,13 +149,12 @@ func (hdlr *Handlers) handlePass(
password := lines[0] password := lines[0]
if len(password) < minPasswordLength || if len(password) < minPasswordLength {
len(password) > maxPasswordLength {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, clientID, sessionID, writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick, irc.ErrNeedMoreParams, nick,
[]string{irc.CmdPass}, []string{irc.CmdPass},
"Password must be 8 to 72 bytes", "Password must be at least 8 characters",
) )
return return
-19
View File
@@ -1,19 +0,0 @@
package handlers
import (
"context"
"sneak.berlin/go/neoirc/internal/db"
)
// SetPollMessages makes HandleGetMessages read the client
// output queue with poll in place of Database.PollMessages.
func (hdlr *Handlers) SetPollMessages(
poll func(
ctx context.Context,
clientID, afterQueueID int64,
limit int,
) ([]db.IRCMessage, int64, error),
) {
hdlr.pollMessages = poll
}
+19 -28
View File
@@ -55,25 +55,16 @@ const spentHashcashTTL = 365 * 24 * time.Hour
// Handlers manages HTTP request handling. // Handlers manages HTTP request handling.
type Handlers struct { type Handlers struct {
params *Params params *Params
log *slog.Logger log *slog.Logger
hc *healthcheck.Healthcheck hc *healthcheck.Healthcheck
broker *broker.Broker broker *broker.Broker
svc *service.Service svc *service.Service
hashcashVal *hashcash.Validator hashcashVal *hashcash.Validator
loginLimiter *ratelimit.Limiter channelHashcash *hashcash.ChannelValidator
stats *stats.Tracker loginLimiter *ratelimit.Limiter
cancelCleanup context.CancelFunc stats *stats.Tracker
cancelCleanup context.CancelFunc
// pollMessages is Database.PollMessages, with which
// HandleGetMessages reads the client output queue. Tests
// replace it to act just after a read, or to make a read
// fail.
pollMessages func(
ctx context.Context,
clientID, afterQueueID int64,
limit int,
) ([]db.IRCMessage, int64, error)
} }
// New creates a new Handlers instance. // New creates a new Handlers instance.
@@ -97,15 +88,15 @@ func New(
} }
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
params: &params, params: &params,
log: params.Logger.Get(), log: params.Logger.Get(),
hc: params.Healthcheck, hc: params.Healthcheck,
broker: params.Broker, broker: params.Broker,
svc: params.Service, svc: params.Service,
hashcashVal: hashcash.NewValidator(resource), hashcashVal: hashcash.NewValidator(resource),
loginLimiter: ratelimit.New(loginRate, loginBurst), channelHashcash: hashcash.NewChannelValidator(),
stats: params.Stats, loginLimiter: ratelimit.New(loginRate, loginBurst),
pollMessages: params.Database.PollMessages, stats: params.Stats,
} }
lifecycle.Append(fx.Hook{ lifecycle.Append(fx.Hook{
+346 -74
View File
@@ -1,15 +1,56 @@
package handlers package handlers
import ( import (
"encoding/json"
"errors"
"net/http" "net/http"
"strings" "strings"
"time" "time"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc" "sneak.berlin/go/neoirc/pkg/irc"
) )
// dispatchOperCommand routes the server operator commands // maxUserhostNicks is the maximum number of nicks allowed
// OPER, KILL and WALLOPS to their handlers. // in a single USERHOST query (RFC 2812).
const maxUserhostNicks = 5
// dispatchBodyOnlyCommand routes commands that take
// (writer, request, sessionID, clientID, nick, bodyLines).
func (hdlr *Handlers) dispatchBodyOnlyCommand(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, command string,
bodyLines func() []string,
) {
switch command {
case irc.CmdAway:
hdlr.handleAway(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdNick:
hdlr.handleNick(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdPass:
hdlr.handlePass(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdInvite:
hdlr.handleInvite(
writer, request,
sessionID, clientID, nick, bodyLines,
)
}
}
// dispatchOperCommand routes oper-related commands (OPER,
// KILL, WALLOPS) to their handlers.
func (hdlr *Handlers) dispatchOperCommand( func (hdlr *Handlers) dispatchOperCommand(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -36,8 +77,8 @@ func (hdlr *Handlers) dispatchOperCommand(
} }
} }
// handleUserhost handles USERHOST: user@host for each of // handleUserhost handles the USERHOST command.
// up to five nicks given in the body. // Returns user@host info for up to 5 nicks.
func (hdlr *Handlers) handleUserhost( func (hdlr *Handlers) handleUserhost(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -45,6 +86,8 @@ func (hdlr *Handlers) handleUserhost(
nick string, nick string,
bodyLines func() []string, bodyLines func() []string,
) { ) {
ctx := request.Context()
lines := bodyLines() lines := bodyLines()
if len(lines) == 0 { if len(lines) == 0 {
hdlr.respondIRCError( hdlr.respondIRCError(
@@ -57,47 +100,107 @@ func (hdlr *Handlers) handleUserhost(
return return
} }
reply, err := hdlr.svc.UserhostReply( // Limit to 5 nicks per RFC 2812.
request.Context(), lines, hdlr.serverName(), nicks := lines
if len(nicks) > maxUserhostNicks {
nicks = nicks[:maxUserhostNicks]
}
infos, err := hdlr.params.Database.GetUserhostInfo(
ctx, nicks,
) )
if hdlr.handleServiceError( if err != nil {
writer, request, clientID, sessionID, nick, err, hdlr.log.Error(
) { "userhost query failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return return
} }
replyStr := hdlr.buildUserhostReply(infos)
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
request.Context(), clientID, ctx, clientID, irc.RplUserHost, nick, nil,
irc.RplUserHost, nick, nil, reply, replyStr,
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"}, map[string]string{"status": "ok"},
http.StatusOK) http.StatusOK)
} }
// handleVersion handles VERSION. // buildUserhostReply builds the RPL_USERHOST reply
// string per RFC 2812.
func (hdlr *Handlers) buildUserhostReply(
infos []db.UserhostInfo,
) string {
replies := make([]string, 0, len(infos))
for idx := range infos {
info := &infos[idx]
username := info.Username
if username == "" {
username = info.Nick
}
hostname := info.Hostname
if hostname == "" {
hostname = hdlr.serverName()
}
operStar := ""
if info.IsOper {
operStar = "*"
}
awayPrefix := "+"
if info.AwayMessage != "" {
awayPrefix = "-"
}
replies = append(replies,
info.Nick+operStar+"="+
awayPrefix+username+"@"+hostname,
)
}
return strings.Join(replies, " ")
}
// handleVersion handles the VERSION command.
// Returns the server version string.
func (hdlr *Handlers) handleVersion( func (hdlr *Handlers) handleVersion(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID, clientID int64, sessionID, clientID int64,
nick string, nick string,
) { ) {
ctx := request.Context()
srvName := hdlr.serverName()
version := hdlr.svc.ServerVersion()
// 351 RPL_VERSION
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
request.Context(), clientID, irc.RplVersion, nick, ctx, clientID, irc.RplVersion, nick,
[]string{ []string{version + ".", srvName},
hdlr.svc.ServerVersion() + ".",
hdlr.serverName(),
},
"", "",
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"}, map[string]string{"status": "ok"},
http.StatusOK) http.StatusOK)
} }
// handleAdmin handles ADMIN. // handleAdmin handles the ADMIN command.
// Returns server admin contact info.
func (hdlr *Handlers) handleAdmin( func (hdlr *Handlers) handleAdmin(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -106,28 +209,40 @@ func (hdlr *Handlers) handleAdmin(
) { ) {
ctx := request.Context() ctx := request.Context()
srvName := hdlr.serverName() srvName := hdlr.serverName()
location1, location2, email := hdlr.svc.AdminLines(srvName)
// 256 RPL_ADMINME
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminMe, nick, ctx, clientID, irc.RplAdminMe, nick,
[]string{srvName}, "Administrative info", []string{srvName},
"Administrative info",
) )
// 257 RPL_ADMINLOC1
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc1, nick, nil, location1, ctx, clientID, irc.RplAdminLoc1, nick, nil,
"neoirc server",
) )
// 258 RPL_ADMINLOC2
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc2, nick, nil, location2, ctx, clientID, irc.RplAdminLoc2, nick, nil,
"IRC over HTTP",
) )
// 259 RPL_ADMINEMAIL
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminEmail, nick, nil, email, ctx, clientID, irc.RplAdminEmail, nick, nil,
"admin@"+srvName,
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"}, map[string]string{"status": "ok"},
http.StatusOK) http.StatusOK)
} }
// handleInfo handles INFO. // handleInfo handles the INFO command.
// Returns server software information.
func (hdlr *Handlers) handleInfo( func (hdlr *Handlers) handleInfo(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -137,41 +252,51 @@ func (hdlr *Handlers) handleInfo(
ctx := request.Context() ctx := request.Context()
for _, line := range hdlr.svc.InfoLines() { for _, line := range hdlr.svc.InfoLines() {
// 371 RPL_INFO
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplInfo, nick, nil, line, ctx, clientID, irc.RplInfo, nick, nil,
line,
) )
} }
// 374 RPL_ENDOFINFO
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfInfo, nick, nil, ctx, clientID, irc.RplEndOfInfo, nick, nil,
"End of /INFO list", "End of /INFO list",
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"}, map[string]string{"status": "ok"},
http.StatusOK) http.StatusOK)
} }
// handleTime handles TIME: the server's local time. // handleTime handles the TIME command.
// Returns the server's local time in RFC format.
func (hdlr *Handlers) handleTime( func (hdlr *Handlers) handleTime(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID, clientID int64, sessionID, clientID int64,
nick string, nick string,
) { ) {
ctx := request.Context()
srvName := hdlr.serverName()
// 391 RPL_TIME
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
request.Context(), clientID, irc.RplTime, nick, ctx, clientID, irc.RplTime, nick,
[]string{hdlr.serverName()}, []string{srvName},
time.Now().Format(time.RFC1123), time.Now().Format(time.RFC1123),
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"}, map[string]string{"status": "ok"},
http.StatusOK) http.StatusOK)
} }
// handleKill handles KILL: the body is the target nick and // handleKill handles the KILL command.
// an optional reason. // Forcibly disconnects a user (oper only).
func (hdlr *Handlers) handleKill( func (hdlr *Handlers) handleKill(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -179,9 +304,25 @@ func (hdlr *Handlers) handleKill(
nick string, nick string,
bodyLines func() []string, bodyLines func() []string,
) { ) {
ctx := request.Context()
// Check oper status.
isOper, err := hdlr.params.Database.IsSessionOper(
ctx, sessionID,
)
if err != nil || !isOper {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoPrivileges, nick, nil,
"Permission Denied- You're not an IRC operator",
)
return
}
lines := bodyLines() lines := bodyLines()
targetNick := "" var targetNick string
if len(lines) > 0 { if len(lines) > 0 {
targetNick = strings.TrimSpace(lines[0]) targetNick = strings.TrimSpace(lines[0])
} }
@@ -197,27 +338,52 @@ func (hdlr *Handlers) handleKill(
return return
} }
reason := "" reason := "KILLed"
if len(lines) > 1 { if len(lines) > 1 {
reason = lines[1] reason = lines[1]
} }
err := hdlr.svc.KillUser( targetSID, lookupErr := hdlr.params.Database.
request.Context(), sessionID, nick, GetSessionByNick(ctx, targetNick)
targetNick, reason, if lookupErr != nil {
) hdlr.respondIRCError(
if hdlr.handleServiceError( writer, request, clientID, sessionID,
writer, request, clientID, sessionID, nick, err, irc.ErrNoSuchNick, nick,
) { []string{targetNick},
"No such nick/channel",
)
return return
} }
// Do not allow killing yourself.
if targetSID == sessionID {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCantKillServer, nick, nil,
"You cannot KILL yourself",
)
return
}
quitReason := "Killed (" + nick + " (" + reason + "))"
// 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, hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"}, map[string]string{"status": "ok"},
http.StatusOK) http.StatusOK)
} }
// handleWallops handles WALLOPS: the body is the message. // handleWallops handles the WALLOPS command.
// Broadcasts a message to all users with +w usermode
// (oper only).
func (hdlr *Handlers) handleWallops( func (hdlr *Handlers) handleWallops(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -225,6 +391,22 @@ func (hdlr *Handlers) handleWallops(
nick string, nick string,
bodyLines func() []string, bodyLines func() []string,
) { ) {
ctx := request.Context()
// Check oper status.
isOper, err := hdlr.params.Database.IsSessionOper(
ctx, sessionID,
)
if err != nil || !isOper {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoPrivileges, nick, nil,
"Permission Denied- You're not an IRC operator",
)
return
}
lines := bodyLines() lines := bodyLines()
if len(lines) == 0 { if len(lines) == 0 {
hdlr.respondIRCError( hdlr.respondIRCError(
@@ -237,24 +419,54 @@ func (hdlr *Handlers) handleWallops(
return return
} }
err := hdlr.svc.SendWallops( message := strings.Join(lines, " ")
request.Context(), sessionID, nick,
strings.Join(lines, " "), wallopsSIDs, err := hdlr.params.Database.
) GetWallopsSessionIDs(ctx)
if hdlr.handleServiceError( if err != nil {
writer, request, clientID, sessionID, nick, err, hdlr.log.Error(
) { "get wallops sessions failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return return
} }
if len(wallopsSIDs) > 0 {
body, mErr := json.Marshal([]string{message})
if mErr != nil {
hdlr.log.Error(
"marshal wallops body", "error", mErr,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
_ = hdlr.fanOutSilent(
request, irc.CmdWallops, nick, "*",
json.RawMessage(body), wallopsSIDs,
)
}
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"}, map[string]string{"status": "ok"},
http.StatusOK) http.StatusOK)
} }
// handleUserMode handles MODE for a nick: a query when the // handleUserMode handles user mode queries and changes
// body is empty, otherwise a change by the mode string in // (e.g., MODE nick, MODE nick +w). Delegates to the
// the body. Only the caller's own nick is allowed. // shared service.ApplyUserMode / service.QueryUserMode so
// that mode string processing is identical for both the
// HTTP API and IRC wire protocol.
func (hdlr *Handlers) handleUserMode( func (hdlr *Handlers) handleUserMode(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -262,7 +474,14 @@ func (hdlr *Handlers) handleUserMode(
nick, target string, nick, target string,
bodyLines func() []string, bodyLines func() []string,
) { ) {
if target != nick { ctx := request.Context()
// 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( hdlr.respondIRCError(
writer, request, clientID, sessionID, writer, request, clientID, sessionID,
irc.ErrUsersDoNotMatch, nick, nil, irc.ErrUsersDoNotMatch, nick, nil,
@@ -272,33 +491,86 @@ func (hdlr *Handlers) handleUserMode(
return return
} }
ctx := request.Context()
var (
modes string
err error
)
lines := bodyLines() lines := bodyLines()
// Mode change requested.
if len(lines) > 0 { if len(lines) > 0 {
modes, err = hdlr.svc.ApplyUserMode( hdlr.changeUserMode(
ctx, sessionID, lines[0], writer, request,
sessionID, clientID, nick, lines[0],
) )
} else {
modes, err = hdlr.svc.QueryUserMode(ctx, sessionID) return
} }
if hdlr.handleServiceError( // Mode query — delegate to shared service.
writer, request, clientID, sessionID, nick, err, 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 return
} }
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplUmodeIs, nick, nil, modes, ctx, clientID, irc.RplUmodeIs, nick, nil,
modeStr,
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"}, 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, modeStr,
)
if err != nil {
var ircErr *service.IRCError
if errors.As(err, &ircErr) {
hdlr.respondIRCError(
writer, request,
clientID, sessionID,
ircErr.Code, nick, ircErr.Params,
ircErr.Message,
)
return
}
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUmodeIs, nick, nil,
newModes,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK) http.StatusOK)
} }
File diff suppressed because it is too large Load Diff
+9 -53
View File
@@ -77,13 +77,9 @@ func NewValidator(resource string) *Validator {
return validator return validator
} }
// Check returns nil if requiredBits is not positive, or if // Validate checks a hashcash stamp. It returns nil if the
// the stamp is a well-formed, recently dated proof of work // stamp is valid and has not been seen before.
// for this resource with at least requiredBits. It does not func (v *Validator) Validate(
// look at or change the set of spent stamps, so a request
// can check its stamp first and Claim it after its other
// checks.
func (v *Validator) Check(
stamp string, stamp string,
requiredBits int, requiredBits int,
) error { ) error {
@@ -91,49 +87,9 @@ func (v *Validator) Check(
return nil return nil
} }
_, err := v.check(stamp, requiredBits)
return err
}
// Claim checks the stamp as Check does and, when
// requiredBits is positive, records it as spent until it
// expires. It returns an error if the stamp is already
// spent. Release gives a claimed stamp back.
func (v *Validator) Claim(
stamp string,
requiredBits int,
) error {
if requiredBits <= 0 {
return nil
}
stampTime, err := v.check(stamp, requiredBits)
if err != nil {
return err
}
return v.checkAndRecordStamp(stamp, stampTime)
}
// Release forgets a claimed stamp so that it can be claimed
// again, for a request that failed after its Claim.
// Releasing a stamp that is not claimed does nothing.
func (v *Validator) Release(stamp string) {
v.mu.Lock()
defer v.mu.Unlock()
delete(v.spent, stamp)
}
// check returns the stamp's date if Check accepts it.
func (v *Validator) check(
stamp string,
requiredBits int,
) (time.Time, error) {
parts := strings.Split(stamp, ":") parts := strings.Split(stamp, ":")
if len(parts) != stampFields { if len(parts) != stampFields {
return time.Time{}, fmt.Errorf( return fmt.Errorf(
"%w: expected %d, got %d", "%w: expected %d, got %d",
errInvalidFields, stampFields, len(parts), errInvalidFields, stampFields, len(parts),
) )
@@ -148,25 +104,25 @@ func (v *Validator) check(
version, bitsStr, resource, requiredBits, version, bitsStr, resource, requiredBits,
) )
if err != nil { if err != nil {
return time.Time{}, err return err
} }
stampTime, err := parseStampDate(dateStr) stampTime, err := parseStampDate(dateStr)
if err != nil { if err != nil {
return time.Time{}, err return err
} }
err = validateTime(stampTime) err = validateTime(stampTime)
if err != nil { if err != nil {
return time.Time{}, err return err
} }
err = validateProof(stamp, requiredBits) err = validateProof(stamp, requiredBits)
if err != nil { if err != nil {
return time.Time{}, err return err
} }
return stampTime, nil return v.checkAndRecordStamp(stamp, stampTime)
} }
func (v *Validator) validateHeader( func (v *Validator) validateHeader(
+14 -76
View File
@@ -85,7 +85,7 @@ func TestMintAndValidate(t *testing.T) {
t, testBits, "test-resource", todayDate(), t, testBits, "test-resource", todayDate(),
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("valid stamp rejected: %v", err) t.Fatalf("valid stamp rejected: %v", err)
} }
@@ -99,74 +99,17 @@ func TestReplayDetection(t *testing.T) {
t, testBits, "test-resource", todayDate(), t, testBits, "test-resource", todayDate(),
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("first use failed: %v", err) t.Fatalf("first use failed: %v", err)
} }
err = validator.Claim(stamp, testBits) err = validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("replay not detected") t.Fatal("replay not detected")
} }
} }
// TestCheckDoesNotSpendStamp checks that a stamp Check
// accepts can still be claimed, and only once, and that
// Check still accepts it once it is spent.
func TestCheckDoesNotSpendStamp(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := mintStampWithDate(
t, testBits, "test-resource", todayDate(),
)
for range 2 {
err := validator.Check(stamp, testBits)
if err != nil {
t.Fatalf("valid stamp rejected: %v", err)
}
}
err := validator.Claim(stamp, testBits)
if err != nil {
t.Fatalf("claim after check failed: %v", err)
}
err = validator.Claim(stamp, testBits)
if err == nil {
t.Fatal("replay not detected")
}
err = validator.Check(stamp, testBits)
if err != nil {
t.Fatalf("spent stamp rejected by Check: %v", err)
}
}
// TestReleasedStampCanBeClaimedAgain checks that Release
// gives a claimed stamp back.
func TestReleasedStampCanBeClaimedAgain(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := mintStampWithDate(
t, testBits, "test-resource", todayDate(),
)
err := validator.Claim(stamp, testBits)
if err != nil {
t.Fatalf("first claim failed: %v", err)
}
validator.Release(stamp)
err = validator.Claim(stamp, testBits)
if err != nil {
t.Fatalf("claim after release failed: %v", err)
}
}
func TestResourceMismatch(t *testing.T) { func TestResourceMismatch(t *testing.T) {
t.Parallel() t.Parallel()
@@ -175,7 +118,7 @@ func TestResourceMismatch(t *testing.T) {
t, testBits, "wrong-resource", todayDate(), t, testBits, "wrong-resource", todayDate(),
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected resource mismatch error") t.Fatal("expected resource mismatch error")
} }
@@ -186,7 +129,7 @@ func TestInvalidStampFormat(t *testing.T) {
validator := hashcash.NewValidator("test-resource") validator := hashcash.NewValidator("test-resource")
err := validator.Claim( err := validator.Validate(
"not:a:valid:stamp", testBits, "not:a:valid:stamp", testBits,
) )
if err == nil { if err == nil {
@@ -203,7 +146,7 @@ func TestBadVersion(t *testing.T) {
testBits, todayDate(), "test-resource", testBits, todayDate(), "test-resource",
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected bad version error") t.Fatal("expected bad version error")
} }
@@ -219,7 +162,7 @@ func TestInsufficientDifficulty(t *testing.T) {
todayDate(), "test-resource", todayDate(), "test-resource",
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected insufficient bits error") t.Fatal("expected insufficient bits error")
} }
@@ -235,7 +178,7 @@ func TestExpiredStamp(t *testing.T) {
t, testBits, "test-resource", oldDate, t, testBits, "test-resource", oldDate,
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected expired stamp error") t.Fatal("expected expired stamp error")
} }
@@ -246,14 +189,9 @@ func TestZeroBitsSkipsValidation(t *testing.T) {
validator := hashcash.NewValidator("test-resource") validator := hashcash.NewValidator("test-resource")
err := validator.Check("garbage", 0) err := validator.Validate("garbage", 0)
if err != nil { if err != nil {
t.Fatalf("zero bits should skip the check: %v", err) t.Fatalf("zero bits should skip: %v", err)
}
err = validator.Claim("garbage", 0)
if err != nil {
t.Fatalf("zero bits should skip the claim: %v", err)
} }
} }
@@ -266,7 +204,7 @@ func TestLongDateFormat(t *testing.T) {
t, testBits, "test-resource", longDate, t, testBits, "test-resource", longDate,
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("long date stamp rejected: %v", err) t.Fatalf("long date stamp rejected: %v", err)
} }
@@ -281,7 +219,7 @@ func TestBadDateFormat(t *testing.T) {
testBits, "test-resource", testBits, "test-resource",
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected bad date error") t.Fatal("expected bad date error")
} }
@@ -297,7 +235,7 @@ func TestMultipleUniqueStamps(t *testing.T) {
t, testBits, "test-resource", todayDate(), t, testBits, "test-resource", todayDate(),
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("unique stamp rejected: %v", err) t.Fatalf("unique stamp rejected: %v", err)
} }
@@ -313,7 +251,7 @@ func TestHigherBitsStillValid(t *testing.T) {
t, 4, "test-resource", todayDate(), t, 4, "test-resource", todayDate(),
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf( t.Fatalf(
"higher-difficulty stamp rejected: %v", "higher-difficulty stamp rejected: %v",
+275 -145
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -14,7 +13,7 @@ import (
) )
// sendIRCError maps a service.IRCError to an IRC numeric // sendIRCError maps a service.IRCError to an IRC numeric
// reply on the wire, and logs any other error. // reply on the wire.
func (c *Conn) sendIRCError(err error) { func (c *Conn) sendIRCError(err error) {
var ircErr *service.IRCError var ircErr *service.IRCError
if errors.As(err, &ircErr) { if errors.As(err, &ircErr) {
@@ -22,11 +21,7 @@ func (c *Conn) sendIRCError(err error) {
args = append(args, ircErr.Params...) args = append(args, ircErr.Params...)
args = append(args, ircErr.Message) args = append(args, ircErr.Message)
c.sendNumeric(ircErr.Code, args...) c.sendNumeric(ircErr.Code, args...)
return
} }
c.log.Error("command failed", "error", err)
} }
// handleCAP silently acknowledges CAP negotiation. // handleCAP silently acknowledges CAP negotiation.
@@ -71,20 +66,15 @@ func (c *Conn) handleNick(
} }
newNick := msg.Params[0] newNick := msg.Params[0]
if !service.ValidNick(newNick) { if len(newNick) > maxNickLen {
c.sendNumeric( newNick = newNick[:maxNickLen]
irc.ErrErroneusNickname,
newNick, "Erroneous nickname",
)
return
} }
oldMask := c.hostmask() oldMask := c.hostmask()
oldNick := c.currentNick() oldNick := c.nick
err := c.svc.ChangeNick( err := c.svc.ChangeNick(
ctx, c.sessionID, c.clientID, oldNick, newNick, ctx, c.sessionID, oldNick, newNick,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -92,10 +82,9 @@ func (c *Conn) handleNick(
return return
} }
// Take the nick the session holds, not newNick: another c.mu.Lock()
// client of the session may have changed it again since c.nick = newNick
// ChangeNick stored newNick. c.mu.Unlock()
c.loadSessionNick(ctx)
// Echo NICK change to the client on wire. // Echo NICK change to the client on wire.
c.send(FormatMessage(oldMask, "NICK", newNick)) c.send(FormatMessage(oldMask, "NICK", newNick))
@@ -129,8 +118,8 @@ func (c *Conn) handlePrivmsg(
body, _ := json.Marshal([]string{text}) //nolint:errchkjson body, _ := json.Marshal([]string{text}) //nolint:errchkjson
if strings.HasPrefix(target, "#") { if strings.HasPrefix(target, "#") {
_, err := c.svc.SendChannelMessage( _, _, err := c.svc.SendChannelMessage(
ctx, c.sessionID, c.clientID, c.currentNick(), ctx, c.sessionID, c.nick,
msg.Command, target, body, nil, msg.Command, target, body, nil,
) )
if err != nil { if err != nil {
@@ -138,7 +127,7 @@ func (c *Conn) handlePrivmsg(
} }
} else { } else {
result, err := c.svc.SendDirectMessage( result, err := c.svc.SendDirectMessage(
ctx, c.sessionID, c.clientID, c.currentNick(), ctx, c.sessionID, c.nick,
msg.Command, target, body, nil, msg.Command, target, body, nil,
) )
if err != nil { if err != nil {
@@ -156,9 +145,7 @@ func (c *Conn) handlePrivmsg(
} }
// handleJoin joins one or more channels via the shared // handleJoin joins one or more channels via the shared
// service layer. The optional second parameter holds the // service layer.
// channel keys, comma-separated in the order of the
// channels.
func (c *Conn) handleJoin( func (c *Conn) handleJoin(
ctx context.Context, ctx context.Context,
msg *Message, msg *Message,
@@ -172,24 +159,14 @@ func (c *Conn) handleJoin(
return return
} }
var keys []string for chanName := range strings.SplitSeq(msg.Params[0], ",") {
if len(msg.Params) > 1 {
keys = strings.Split(msg.Params[1], ",")
}
for idx, chanName := range strings.Split(msg.Params[0], ",") {
chanName = strings.TrimSpace(chanName) chanName = strings.TrimSpace(chanName)
if !strings.HasPrefix(chanName, "#") { if !strings.HasPrefix(chanName, "#") {
chanName = "#" + chanName chanName = "#" + chanName
} }
key := "" c.joinChannel(ctx, chanName)
if idx < len(keys) {
key = keys[idx]
}
c.joinChannel(ctx, chanName, key)
} }
} }
@@ -197,10 +174,10 @@ func (c *Conn) handleJoin(
// and delivers topic/names on the wire. // and delivers topic/names on the wire.
func (c *Conn) joinChannel( func (c *Conn) joinChannel(
ctx context.Context, ctx context.Context,
channel, key string, channel string,
) { ) {
result, err := c.svc.JoinChannel( result, err := c.svc.JoinChannel(
ctx, c.sessionID, c.clientID, c.currentNick(), channel, key, ctx, c.sessionID, c.nick, channel, "",
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -338,7 +315,7 @@ func (c *Conn) partChannel(
channel, reason string, channel, reason string,
) { ) {
err := c.svc.PartChannel( err := c.svc.PartChannel(
ctx, c.sessionID, c.clientID, c.currentNick(), channel, reason, ctx, c.sessionID, c.nick, channel, reason,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -402,7 +379,7 @@ func (c *Conn) handleTopic(
newTopic := msg.Params[1] newTopic := msg.Params[1]
err := c.svc.SetTopic( err := c.svc.SetTopic(
ctx, c.sessionID, c.clientID, c.currentNick(), channel, newTopic, ctx, c.sessionID, c.nick, channel, newTopic,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -702,26 +679,24 @@ func (c *Conn) applyChannelModes(
if applied.Len() > 0 { if applied.Len() > 0 {
modeReply := applied.String() + appliedArgs.String() modeReply := applied.String() + appliedArgs.String()
params := slices.Concat( c.send(FormatMessage(
[]string{channel}, strings.Fields(modeReply), c.hostmask(), "MODE", channel, modeReply,
) ))
c.send(FormatMessage(c.hostmask(), "MODE", params...))
c.svc.BroadcastMode( c.svc.BroadcastMode(
ctx, c.clientID, c.currentNick(), channel, chID, modeReply, ctx, c.nick, channel, chID, modeReply,
) )
} }
} }
// handleUserMode handles MODE for a nick: a query without a // handleUserMode handles MODE for users.
// mode string, otherwise a change. Only the client's own
// nick is allowed.
func (c *Conn) handleUserMode( func (c *Conn) handleUserMode(
ctx context.Context, ctx context.Context,
msg *Message, msg *Message,
) { ) {
if msg.Params[0] != c.currentNick() { target := msg.Params[0]
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,26 +705,48 @@ func (c *Conn) handleUserMode(
return return
} }
var ( // Mode query (no mode string).
modes string if len(msg.Params) < 2 { //nolint:mnd
err error modes, err := c.svc.QueryUserMode(
) ctx, c.sessionID,
if len(msg.Params) == 1 {
modes, err = c.svc.QueryUserMode(ctx, c.sessionID)
} else {
modes, err = c.svc.ApplyUserMode(
ctx, c.sessionID, msg.Params[1],
) )
} if err != nil {
c.log.Error(
"query user mode failed", "error", err,
)
c.sendNumeric(
irc.ErrUmodeUnknownFlag,
"Unable to read user modes",
)
if err != nil { return
c.sendIRCError(err) }
c.sendNumeric(irc.RplUmodeIs, modes)
return return
} }
c.sendNumeric(irc.RplUmodeIs, modes) newModes, err := c.svc.ApplyUserMode(
ctx, c.sessionID, msg.Params[1],
)
if err != nil {
var ircErr *service.IRCError
if errors.As(err, &ircErr) {
c.sendNumeric(ircErr.Code, ircErr.Message)
return
}
c.sendNumeric(
irc.ErrUmodeUnknownFlag,
"Unknown MODE flag",
)
return
}
c.sendNumeric(irc.RplUmodeIs, newModes)
} }
// handleNames replies with channel member list. // handleNames replies with channel member list.
@@ -781,11 +778,10 @@ func (c *Conn) handleNames(
c.deliverNames(ctx, channel, chID) c.deliverNames(ctx, channel, chID)
} }
// handleList sends the channel list, leaving out +s // handleList sends the channel list.
// channels the user is not on.
func (c *Conn) handleList(ctx context.Context) { func (c *Conn) handleList(ctx context.Context) {
channels, err := c.database.ListAllChannelsWithCountsFiltered( channels, err := c.database.ListAllChannelsWithCounts(
ctx, c.sessionID, ctx,
) )
if err != nil { if err != nil {
c.sendNumeric( c.sendNumeric(
@@ -905,15 +901,14 @@ func (c *Conn) whoisOper(
} }
} }
// whoisChannels sends 319 RPL_WHOISCHANNELS, leaving out // whoisChannels sends 319 RPL_WHOISCHANNELS.
// +s channels the querier is not on.
func (c *Conn) whoisChannels( func (c *Conn) whoisChannels(
ctx context.Context, ctx context.Context,
target string, target string,
targetID int64, targetID int64,
) { ) {
userChannels, _ := c.database.GetSessionChannelsFiltered( userChannels, _ := c.database.GetSessionChannels(
ctx, targetID, c.sessionID, ctx, targetID,
) )
if len(userChannels) == 0 { if len(userChannels) == 0 {
return return
@@ -1186,7 +1181,7 @@ func (c *Conn) handleKick(
} }
err := c.svc.KickUser( err := c.svc.KickUser(
ctx, c.sessionID, c.clientID, c.currentNick(), ctx, c.sessionID, c.nick,
channel, targetNick, reason, channel, targetNick, reason,
) )
if err != nil { if err != nil {
@@ -1217,16 +1212,17 @@ func (c *Conn) handlePassPostReg(
} }
password := msg.Params[0] password := msg.Params[0]
if !c.checkPasswordLength(password) { if len(password) < minPasswordLen {
c.sendFromServer("NOTICE", c.nick,
"Password must be at least 8 characters",
)
return return
} }
err := c.setPassword(ctx, password) c.setPassword(ctx, password)
if err != nil {
return
}
c.sendFromServer("NOTICE", c.currentNick(), c.sendFromServer("NOTICE", c.nick,
"Password set. You can reconnect using "+ "Password set. You can reconnect using "+
"PASS <password> with your nick.", "PASS <password> with your nick.",
) )
@@ -1249,11 +1245,36 @@ func (c *Conn) handleInvite(
targetNick := msg.Params[0] targetNick := msg.Params[0]
channel := msg.Params[1] channel := msg.Params[1]
targetID, err := c.svc.InviteUser( chID, err := c.database.GetChannelByName(ctx, channel)
ctx, c.sessionID, c.currentNick(), targetNick, channel, if err != nil {
c.sendNumeric(
irc.ErrNoSuchChannel,
channel, "No such channel",
)
return
}
isMember, _ := c.database.IsChannelMember(
ctx, chID, c.sessionID,
)
if !isMember {
c.sendNumeric(
irc.ErrNotOnChannel,
channel, "You're not on that channel",
)
return
}
targetID, err := c.database.GetSessionByNick(
ctx, targetNick,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendNumeric(
irc.ErrNoSuchNick,
targetNick, "No such nick",
)
return return
} }
@@ -1262,126 +1283,235 @@ func (c *Conn) handleInvite(
irc.RplInviting, targetNick, channel, irc.RplInviting, targetNick, channel,
) )
// Queue the INVITE to the target with the body the HTTP // Send INVITE notice to target via service fan-out.
// API uses. body, _ := json.Marshal( //nolint:errchkjson
body, _ := json.Marshal([]string{channel}) //nolint:errchkjson []string{"You have been invited to " + channel},
)
_, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, "INVITE", c.currentNick(), targetNick, targetID, 0, ctx, "INVITE", c.nick, targetNick,
nil, body, nil, []int64{targetID}, 0, nil, body, nil, []int64{targetID},
) )
} }
// handleUserhost replies with user@host for each of up to // handleUserhost replies with USERHOST info.
// five nicks.
func (c *Conn) handleUserhost( func (c *Conn) handleUserhost(
ctx context.Context, ctx context.Context,
msg *Message, msg *Message,
) { ) {
if len(msg.Params) < 1 { if len(msg.Params) < 1 {
c.sendNumeric( return
irc.ErrNeedMoreParams, }
irc.CmdUserhost, "Not enough parameters",
replies := make([]string, 0, len(msg.Params))
for _, nick := range msg.Params {
sid, err := c.database.GetSessionByNick(ctx, nick)
if err != nil {
continue
}
hostInfo, _ := c.database.GetSessionHostInfo(
ctx, sid,
) )
return host := "*"
if hostInfo != nil {
host = hostInfo.Hostname
}
isOper, _ := c.database.IsSessionOper(ctx, sid)
operStar := ""
if isOper {
operStar = "*"
}
replies = append(
replies,
nick+operStar+"=+"+nick+"@"+host,
)
} }
reply, err := c.svc.UserhostReply( c.sendNumeric(
ctx, msg.Params, c.serverSfx, irc.RplUserHost,
strings.Join(replies, " "),
) )
if err != nil {
c.sendIRCError(err)
return
}
c.sendNumeric(irc.RplUserHost, reply)
} }
// handleVersion replies with the server version. // handleVersion replies with the server version string.
func (c *Conn) handleVersion() { func (c *Conn) handleVersion() {
c.sendNumeric( c.sendNumeric(
irc.RplVersion, irc.RplVersion,
c.svc.ServerVersion()+".", c.serverSfx, "", c.svc.ServerVersion()+".", c.serverSfx,
"",
) )
} }
// handleAdmin replies with the server's admin info. // handleAdmin replies with server admin info.
func (c *Conn) handleAdmin() { func (c *Conn) handleAdmin() {
location1, location2, email := c.svc.AdminLines(c.serverSfx) srvName := c.serverSfx
c.sendNumeric( c.sendNumeric(
irc.RplAdminMe, c.serverSfx, "Administrative info", irc.RplAdminMe,
srvName, "Administrative info",
)
c.sendNumeric(
irc.RplAdminLoc1,
"neoirc server",
)
c.sendNumeric(
irc.RplAdminLoc2,
"IRC over HTTP",
)
c.sendNumeric(
irc.RplAdminEmail,
"admin@"+srvName,
) )
c.sendNumeric(irc.RplAdminLoc1, location1)
c.sendNumeric(irc.RplAdminLoc2, location2)
c.sendNumeric(irc.RplAdminEmail, email)
} }
// handleInfo replies with the server's software info. // handleInfo replies with server software info.
func (c *Conn) handleInfo() { func (c *Conn) handleInfo() {
for _, line := range c.svc.InfoLines() { for _, line := range c.svc.InfoLines() {
c.sendNumeric(irc.RplInfo, line) c.sendNumeric(irc.RplInfo, line)
} }
c.sendNumeric(irc.RplEndOfInfo, "End of /INFO list")
}
// handleTime replies with the server's local time.
func (c *Conn) handleTime() {
c.sendNumeric( c.sendNumeric(
irc.RplTime, irc.RplEndOfInfo,
c.serverSfx, time.Now().Format(time.RFC1123), "End of /INFO list",
) )
} }
// handleKill handles KILL <nick> [<reason>]. // handleTime replies with the server's current time.
func (c *Conn) handleKill( func (c *Conn) handleTime() {
srvName := c.serverSfx
c.sendNumeric(
irc.RplTime,
srvName, time.Now().Format(time.RFC1123),
)
}
// handleKillCmd forcibly disconnects a target user (oper
// only).
func (c *Conn) handleKillCmd(
ctx context.Context, ctx context.Context,
msg *Message, msg *Message,
) { ) {
if len(msg.Params) < 1 { isOper, err := c.database.IsSessionOper(
ctx, c.sessionID,
)
if err != nil || !isOper {
c.sendNumeric( c.sendNumeric(
irc.ErrNeedMoreParams, irc.ErrNoPrivileges,
irc.CmdKill, "Not enough parameters", "Permission Denied- "+
"You're not an IRC operator",
) )
return return
} }
reason := "" if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
"KILL", "Not enough parameters",
)
return
}
targetNick := msg.Params[0]
reason := "KILLed"
if len(msg.Params) > 1 { if len(msg.Params) > 1 {
reason = msg.Params[1] reason = msg.Params[1]
} }
err := c.svc.KillUser( killerNick := c.currentNick()
ctx, c.sessionID, c.currentNick(),
msg.Params[0], reason,
)
if err != nil {
c.sendIRCError(err)
}
}
// handleWallops handles WALLOPS <message>. if strings.EqualFold(targetNick, killerNick) {
func (c *Conn) handleWallops(
ctx context.Context,
msg *Message,
) {
if len(msg.Params) < 1 {
c.sendNumeric( c.sendNumeric(
irc.ErrNeedMoreParams, irc.ErrCantKillServer,
irc.CmdWallops, "Not enough parameters", "You cannot KILL yourself",
) )
return return
} }
err := c.svc.SendWallops( targetSID, lookupErr := c.database.GetSessionByNick(
ctx, c.sessionID, c.currentNick(), msg.Params[0], ctx, targetNick,
) )
if err != nil { if lookupErr != nil {
c.sendIRCError(err) c.sendNumeric(
irc.ErrNoSuchNick,
targetNick, "No such nick/channel",
)
return
}
quitReason := "Killed (" + killerNick +
" (" + reason + "))"
// KillSession broadcasts the QUIT, deletes the session
// and disconnects the victim's wire connection.
c.svc.KillSession(
ctx, targetSID, targetNick, quitReason,
)
}
// handleWallopsCmd broadcasts to all +w users (oper only).
func (c *Conn) handleWallopsCmd(
ctx context.Context,
msg *Message,
) {
isOper, err := c.database.IsSessionOper(
ctx, c.sessionID,
)
if err != nil || !isOper {
c.sendNumeric(
irc.ErrNoPrivileges,
"Permission Denied- "+
"You're not an IRC operator",
)
return
}
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
"WALLOPS", "Not enough parameters",
)
return
}
message := msg.Params[0]
wallopsSIDs, wallErr := c.database.
GetWallopsSessionIDs(ctx)
if wallErr != nil {
c.log.Error(
"get wallops sessions failed",
"error", wallErr,
)
return
}
if len(wallopsSIDs) > 0 {
body, mErr := json.Marshal([]string{message})
if mErr != nil {
return
}
_, _, _ = c.svc.FanOut(
ctx, irc.CmdWallops, c.currentNick(), "*",
nil, body, nil, wallopsSIDs,
)
} }
} }
+48 -131
View File
@@ -20,14 +20,15 @@ import (
const ( const (
maxLineLen = 512 maxLineLen = 512
readTimeout = 5 * time.Minute
writeTimeout = 30 * time.Second writeTimeout = 30 * time.Second
killWriteWindow = 2 * time.Second killWriteWindow = 2 * time.Second
dnsTimeout = 3 * time.Second dnsTimeout = 3 * time.Second
pollInterval = 100 * time.Millisecond pollInterval = 100 * time.Millisecond
pingInterval = 90 * time.Second pingInterval = 90 * time.Second
pongDeadline = 30 * time.Second pongDeadline = 30 * time.Second
maxNickLen = 32
minPasswordLen = 8 minPasswordLen = 8
maxPasswordLen = 72 // bcrypt cannot hash a longer one
maxHashcashBits = 40 maxHashcashBits = 40
) )
@@ -46,9 +47,10 @@ type Conn struct {
serverSfx string serverSfx string
commands map[string]cmdHandler commands map[string]cmdHandler
// writeMu serializes writes to conn, which come from // writeMu serializes writes to conn. A connection is
// serve(), from the relay goroutine and, for KILL, from // written to by its own read loop, by its relay
// Disconnect. // goroutine, and — when an operator KILLs it — by
// another client's goroutine.
writeMu sync.Mutex writeMu sync.Mutex
mu sync.Mutex mu sync.Mutex
@@ -63,7 +65,6 @@ type Conn struct {
registered bool registered bool
gotNick bool gotNick bool
gotUser bool gotUser bool
gotPass bool
passWord string passWord string
lastQueueID int64 lastQueueID int64
@@ -72,6 +73,7 @@ type Conn struct {
} }
func newConn( func newConn(
ctx context.Context,
tcpConn net.Conn, tcpConn net.Conn,
log *slog.Logger, log *slog.Logger,
database *db.Database, database *db.Database,
@@ -95,6 +97,7 @@ func newConn(
svc: svc, svc: svc,
serverSfx: srvName, serverSfx: srvName,
remoteIP: host, remoteIP: host,
hostname: resolveHost(ctx, host),
} }
conn.commands = conn.buildCommandMap() conn.commands = conn.buildCommandMap()
@@ -138,7 +141,7 @@ func (c *Conn) Disconnect(reason string) {
func (c *Conn) notifyKilledAndClose( func (c *Conn) notifyKilledAndClose(
nick, host, reason string, nick, host, reason string,
) { ) {
defer func() { _ = c.conn.Close() }() defer c.conn.Close() //nolint:errcheck
c.sendWithin( c.sendWithin(
killWriteWindow, killWriteWindow,
@@ -185,13 +188,13 @@ func (c *Conn) buildCommandMap() map[string]cmdHandler {
"CAP": func(_ context.Context, msg *Message) { "CAP": func(_ context.Context, msg *Message) {
c.handleCAP(msg) c.handleCAP(msg)
}, },
irc.CmdUserhost: c.handleUserhost, "USERHOST": c.handleUserhost,
irc.CmdVersion: func(context.Context, *Message) { c.handleVersion() }, irc.CmdVersion: func(context.Context, *Message) { c.handleVersion() },
irc.CmdAdmin: func(context.Context, *Message) { c.handleAdmin() }, irc.CmdAdmin: func(context.Context, *Message) { c.handleAdmin() },
irc.CmdInfo: func(context.Context, *Message) { c.handleInfo() }, irc.CmdInfo: func(context.Context, *Message) { c.handleInfo() },
irc.CmdTime: func(context.Context, *Message) { c.handleTime() }, irc.CmdTime: func(context.Context, *Message) { c.handleTime() },
irc.CmdKill: c.handleKill, irc.CmdKill: c.handleKillCmd,
irc.CmdWallops: c.handleWallops, irc.CmdWallops: c.handleWallopsCmd,
} }
} }
@@ -212,42 +215,18 @@ func resolveHost(ctx context.Context, addr string) string {
} }
// serve is the main loop for a single IRC client connection. // serve is the main loop for a single IRC client connection.
// It first sets the client's hostname to what resolve returns
// for the client's address.
// Cancelling ctx when it returns stops the relay goroutine. // Cancelling ctx when it returns stops the relay goroutine.
// When nothing has been read for interval the client is sent func (c *Conn) serve(ctx context.Context) {
// a PING, and when nothing is read within deadline after that
// the connection is closed.
func (c *Conn) serve(
ctx context.Context,
resolve func(ctx context.Context, addr string) string,
interval, deadline time.Duration,
) {
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
defer cancel() defer cancel()
// Stopping the server cancels ctx, and the cleanup must defer c.cleanup(ctx)
// still end the session then.
defer c.cleanup(context.WithoutCancel(ctx))
// The hostname is looked up here, on this connection's
// goroutine, and not when the connection is accepted, so
// that a slow lookup holds up only this client.
c.hostname = resolve(ctx, c.remoteIP)
ping := time.AfterFunc(interval, func() {
c.send("PING :" + c.serverSfx)
})
defer ping.Stop()
scanner := bufio.NewScanner(c.conn) scanner := bufio.NewScanner(c.conn)
scanner.Buffer(make([]byte, maxLineLen), maxLineLen) scanner.Buffer(make([]byte, maxLineLen), maxLineLen)
for { for {
// Every line read starts both waits again.
ping.Reset(interval)
_ = c.conn.SetReadDeadline( _ = c.conn.SetReadDeadline(
time.Now().Add(interval + deadline), time.Now().Add(readTimeout),
) )
if !scanner.Scan() { if !scanner.Scan() {
@@ -284,8 +263,9 @@ func (c *Conn) cleanup(ctx context.Context) {
if wasRegistered && sessID > 0 { if wasRegistered && sessID > 0 {
c.svc.UnregisterWireConn(sessID, c) c.svc.UnregisterWireConn(sessID, c)
// KILL has already sent the QUIT and deleted the // A KILLed session has already been broadcast and
// session. // deleted by the killer; broadcasting again would
// fan out a QUIT for a session row that is gone.
if !wasKilled { if !wasKilled {
c.svc.BroadcastQuit( c.svc.BroadcastQuit(
ctx, sessID, nick, "Connection closed", ctx, sessID, nick, "Connection closed",
@@ -296,8 +276,8 @@ func (c *Conn) cleanup(ctx context.Context) {
c.conn.Close() //nolint:errcheck,gosec c.conn.Close() //nolint:errcheck,gosec
} }
// isClosed reports whether QUIT or KILL has ended the // isClosed reports whether the connection has been marked
// connection. // for teardown, either by QUIT or by an operator KILL.
func (c *Conn) isClosed() bool { func (c *Conn) isClosed() bool {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -305,7 +285,9 @@ func (c *Conn) isClosed() bool {
return c.closed return c.closed
} }
// currentNick returns c.nick, read under c.mu. // 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 { func (c *Conn) currentNick() string {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -313,39 +295,19 @@ func (c *Conn) currentNick() string {
return c.nick return c.nick
} }
// loadSessionNick sets c.nick to the nick the connection's
// session holds in the database, read by session ID. The
// connection's own NICK and the relay passing on a NICK line
// both call it, and it reads and sets the nick under c.mu, so
// whichever runs last leaves the connection on its session's
// nick. If the read fails, c.nick is left as it is.
func (c *Conn) loadSessionNick(ctx context.Context) {
c.mu.Lock()
defer c.mu.Unlock()
nick, err := c.database.GetSessionNick(ctx, c.sessionID)
if err == nil {
c.nick = 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.sendWithin(writeTimeout, line) c.sendWithin(writeTimeout, line)
} }
// sendWithin writes a formatted IRC line to the connection, // sendWithin writes a formatted IRC line to the connection
// giving up after timeout. CR, LF and NUL are removed first, // under the given write deadline. Callers that must not be
// as FormatMessage does for each parameter, so that a line // held hostage by an unresponsive peer pass a shorter window
// built without it cannot end early either. // than writeTimeout.
func (c *Conn) sendWithin( func (c *Conn) sendWithin(
timeout time.Duration, timeout time.Duration,
line string, line string,
) { ) {
line = strings.ReplaceAll(line, "\r", "")
line = strings.ReplaceAll(line, "\n", "")
line = strings.ReplaceAll(line, "\x00", "")
c.writeMu.Lock() c.writeMu.Lock()
defer c.writeMu.Unlock() defer c.writeMu.Unlock()
@@ -361,7 +323,7 @@ func (c *Conn) sendNumeric(
code irc.IRCMessageType, code irc.IRCMessageType,
params ...string, params ...string,
) { ) {
nick := c.currentNick() nick := c.nick
if nick == "" { if nick == "" {
nick = "*" nick = "*"
} }
@@ -385,11 +347,9 @@ func (c *Conn) sendFromServer(
// hostmask returns the client's full hostmask // hostmask returns the client's full hostmask
// (nick!user@host). // (nick!user@host).
func (c *Conn) hostmask() string { func (c *Conn) hostmask() string {
nick := c.currentNick()
user := c.username user := c.username
if user == "" { if user == "" {
user = nick user = c.nick
} }
host := c.hostname host := c.hostname
@@ -397,7 +357,7 @@ func (c *Conn) hostmask() string {
host = c.remoteIP host = c.remoteIP
} }
return nick + "!" + user + "@" + host return c.nick + "!" + user + "@" + host
} }
// handleMessage dispatches a parsed IRC message using // handleMessage dispatches a parsed IRC message using
@@ -407,24 +367,13 @@ func (c *Conn) handleMessage(
msg *Message, msg *Message,
) { ) {
// Before registration, only NICK, USER, PASS, PING, // Before registration, only NICK, USER, PASS, PING,
// PONG, QUIT, and CAP are accepted. // QUIT, and CAP are accepted.
if !c.registered { if !c.registered {
c.handlePreRegistration(ctx, msg) c.handlePreRegistration(ctx, msg)
return return
} }
// Every command is activity, so the idle cleanup must not
// remove this user.
err := c.database.UpdateLastSeen(
ctx, c.sessionID, c.clientID,
)
if err != nil {
c.log.Error(
"failed to update last_seen", "error", err,
)
}
handler, ok := c.commands[msg.Command] handler, ok := c.commands[msg.Command]
if !ok { if !ok {
c.sendNumeric( c.sendNumeric(
@@ -456,7 +405,6 @@ func (c *Conn) handlePreRegistration(
} }
c.passWord = msg.Params[0] c.passWord = msg.Params[0]
c.gotPass = true
case irc.CmdNick: case irc.CmdNick:
if len(msg.Params) < 1 { if len(msg.Params) < 1 {
c.sendNumeric( c.sendNumeric(
@@ -467,16 +415,11 @@ func (c *Conn) handlePreRegistration(
return return
} }
if !service.ValidNick(msg.Params[0]) { c.nick = msg.Params[0]
c.sendNumeric( if len(c.nick) > maxNickLen {
irc.ErrErroneusNickname, c.nick = c.nick[:maxNickLen]
msg.Params[0], "Erroneous nickname",
)
return
} }
c.nick = msg.Params[0]
c.gotNick = true c.gotNick = true
case irc.CmdUser: case irc.CmdUser:
if len(msg.Params) < 4 { //nolint:mnd if len(msg.Params) < 4 { //nolint:mnd
@@ -494,10 +437,6 @@ func (c *Conn) handlePreRegistration(
case irc.CmdPing: case irc.CmdPing:
c.handlePing(msg) c.handlePing(msg)
return
case irc.CmdPong:
// The answer to serve's PING, which unregistered
// clients are sent too.
return return
case irc.CmdQuit: case irc.CmdQuit:
c.handleQuit(msg) c.handleQuit(msg)
@@ -568,23 +507,21 @@ func (c *Conn) completeRegistration(ctx context.Context) {
c.registered = true c.registered = true
c.mu.Unlock() c.mu.Unlock()
// So that KILL, from either transport, can close it. // Make this connection reachable by session ID so that
// KILL from either transport can disconnect it.
c.svc.RegisterWireConn(sessionID, c) c.svc.RegisterWireConn(sessionID, c)
// If PASS was provided before registration, set the
// session password.
if c.passWord != "" && len(c.passWord) >= minPasswordLen {
c.setPassword(ctx, c.passWord)
}
// Send welcome burst. // Send welcome burst.
c.deliverWelcome() c.deliverWelcome()
c.deliverLusers(ctx) c.deliverLusers(ctx)
c.deliverMOTD() c.deliverMOTD()
// If PASS was given before registration, store its
// password now, after the welcome burst, so that a
// NOTICE refusing it reaches a registered client. A
// failure to store it is only logged, as it is for PASS
// after registration.
if c.gotPass && c.checkPasswordLength(c.passWord) {
_ = c.setPassword(ctx, c.passWord)
}
// Start the message relay goroutine. // Start the message relay goroutine.
go c.relayMessages(ctx) go c.relayMessages(ctx)
} }
@@ -667,33 +604,13 @@ func (c *Conn) deliverMOTD() {
) )
} }
// checkPasswordLength reports whether pw is 8 to 72 bytes // setPassword sets a bcrypt password on the session.
// long, and sends the client a NOTICE saying so when it is func (c *Conn) setPassword(ctx context.Context, pw string) {
// not.
func (c *Conn) checkPasswordLength(pw string) bool {
if len(pw) >= minPasswordLen && len(pw) <= maxPasswordLen {
return true
}
c.sendFromServer("NOTICE", c.currentNick(),
"Password must be 8 to 72 bytes",
)
return false
}
// setPassword sets a bcrypt password on the session, and
// logs and returns the error when it was not stored.
func (c *Conn) setPassword(ctx context.Context, pw string) error {
// Use the database's auth module to hash and store. // Use the database's auth module to hash and store.
err := c.database.SetPassword(ctx, c.sessionID, pw) err := c.database.SetPassword(ctx, c.sessionID, pw)
if err != nil { if err != nil {
c.log.Error( c.log.Error(
"failed to set password", "error", err, "failed to set password", "error", err,
) )
return err
} }
return nil
} }
+69 -89
View File
@@ -5,7 +5,6 @@ import (
"log/slog" "log/slog"
"net" "net"
"os" "os"
"slices"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -14,39 +13,43 @@ import (
"sneak.berlin/go/neoirc/internal/ircserver" "sneak.berlin/go/neoirc/internal/ircserver"
) )
// newKillVictim returns a Conn for the nick victim whose // disconnectBudget is how long Disconnect is allowed to
// socket is one end of a net.Pipe, and the other end, from // take when the victim never reads. It is far below the
// which the test reads what the victim is sent. A write to // 30s writeTimeout that the old synchronous implementation
// the pipe blocks until the other end reads. // would have burned on each of its two writes.
func newKillVictim(t *testing.T) (*ircserver.Conn, net.Conn) { const disconnectBudget = 2 * time.Second
t.Helper()
serverSide, clientSide := net.Pipe() // TestDisconnectDoesNotBlockOnUnresponsiveVictim proves
// that an operator KILL cannot be stalled by its target.
t.Cleanup(func() { _ = clientSide.Close() }) // The victim's socket is a net.Pipe, so every write blocks
// until the peer reads and the peer here never does. The
log := slog.New(slog.NewTextHandler( // old implementation performed both notification writes on
os.Stderr, // the killer's goroutine, which wedged the operator's own
&slog.HandlerOptions{Level: slog.LevelError}, // serve() loop on the IRC path and the API request on the
)) // HTTP path for as long as the victim cared to stay silent.
cfg := &config.Config{ServerName: testServerName}
return ircserver.NewTestConn(
log, cfg, serverSide, "victim",
), clientSide
}
// TestDisconnectDoesNotBlockOnUnresponsiveVictim checks
// that Disconnect returns although the victim never reads,
// so a KILL cannot stall the operator who sent it. The
// limit is well below one KillWriteWindow, so a Disconnect
// that waits on any write to the victim fails.
func TestDisconnectDoesNotBlockOnUnresponsiveVictim( func TestDisconnectDoesNotBlockOnUnresponsiveVictim(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
victim, _ := newKillVictim(t) serverSide, clientSide := net.Pipe()
t.Cleanup(func() {
_ = clientSide.Close()
})
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
))
cfg := &config.Config{ //nolint:exhaustruct
ServerName: "test.irc",
}
victim := ircserver.NewTestConn(
log, cfg, serverSide, "victim",
)
returned := make(chan struct{}) returned := make(chan struct{})
go func() { go func() {
@@ -56,44 +59,66 @@ func TestDisconnectDoesNotBlockOnUnresponsiveVictim(
select { select {
case <-returned: case <-returned:
case <-time.After(ircserver.KillWriteWindow / 4): case <-time.After(disconnectBudget):
t.Fatal("Disconnect waited for the victim to read") t.Fatal(
"Disconnect blocked on the victim's socket; " +
"the killer must not be held hostage",
)
} }
} }
// TestDisconnectNotifiesAndClosesVictim checks that a // TestDisconnectNotifiesAndClosesVictim is the other half
// victim that reads is sent KILL and ERROR, and that its // of the contract: moving the notification off the killer's
// connection is then closed. // goroutine must not lose it. A victim that does read gets
// both the KILL and the ERROR line, and then its socket is
// closed so its read loop unblocks.
func TestDisconnectNotifiesAndClosesVictim(t *testing.T) { func TestDisconnectNotifiesAndClosesVictim(t *testing.T) {
t.Parallel() t.Parallel()
victim, clientSide := newKillVictim(t) serverSide, clientSide := net.Pipe()
received := make(chan []string, 1)
t.Cleanup(func() {
_ = clientSide.Close()
})
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
))
cfg := &config.Config{ //nolint:exhaustruct
ServerName: "test.irc",
}
victim := ircserver.NewTestConn(
log, cfg, serverSide, "victim",
)
lines := make(chan []string, 1)
go func() { go func() {
var lines []string var got []string
scanner := bufio.NewScanner(clientSide) scanner := bufio.NewScanner(clientSide)
for scanner.Scan() { for scanner.Scan() {
lines = append(lines, scanner.Text()) got = append(got, scanner.Text())
} }
received <- lines lines <- got
}() }()
victim.Disconnect("killed by oper") victim.Disconnect("killed by oper")
var lines []string var got []string
select { select {
case lines = <-received: case got = <-lines:
case <-time.After(testTimeout): case <-time.After(5 * time.Second):
t.Fatal("the victim's connection was not closed") t.Fatal("victim socket was never closed")
} }
joined := strings.Join(lines, "\n") joined := strings.Join(got, "\n")
if !strings.Contains(joined, "KILL victim :killed by oper") { if !strings.Contains(joined, "KILL victim") {
t.Errorf("missing KILL line, got: %q", joined) t.Errorf("missing KILL line, got: %q", joined)
} }
@@ -101,48 +126,3 @@ func TestDisconnectNotifiesAndClosesVictim(t *testing.T) {
t.Errorf("missing ERROR line, got: %q", joined) t.Errorf("missing ERROR line, got: %q", joined)
} }
} }
// TestDisconnectReasonCannotStartLine checks that CR, LF and
// NUL in a KILL reason are removed from both the KILL line
// and the ERROR line, so the victim gets exactly those two
// lines. The ERROR line is not built with FormatMessage, so
// only the removal in sendWithin protects it.
func TestDisconnectReasonCannotStartLine(t *testing.T) {
t.Parallel()
victim, clientSide := newKillVictim(t)
received := make(chan []string, 1)
go func() {
var lines []string
scanner := bufio.NewScanner(clientSide)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
received <- lines
}()
victim.Disconnect("bye\r\nPRIVMSG victim :forged\x00")
var lines []string
select {
case lines = <-received:
case <-time.After(testTimeout):
t.Fatal("the victim's connection was not closed")
}
// The test connection has no host name, hence the two
// spaces after "Closing Link:".
want := []string{
":" + testServerName +
" KILL victim :byePRIVMSG victim :forged",
"ERROR :Closing Link: (byePRIVMSG victim :forged)",
}
if !slices.Equal(lines, want) {
t.Errorf("got lines %q, want %q", lines, want)
}
}
+8 -51
View File
@@ -13,18 +13,13 @@ import (
"sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/service"
) )
// NewTestServer creates a Server suitable for testing, whose // NewTestServer creates a Server suitable for testing.
// connections use interval and deadline in place of
// pingInterval and pongDeadline, and resolve in place of
// resolveHost.
// The caller must call Stop() when finished. // The caller must call Stop() when finished.
func NewTestServer( func NewTestServer(
log *slog.Logger, log *slog.Logger,
cfg *config.Config, cfg *config.Config,
database *db.Database, database *db.Database,
brk *broker.Broker, brk *broker.Broker,
interval, deadline time.Duration,
resolve func(ctx context.Context, addr string) string,
) *Server { ) *Server {
globs := &globals.Globals{ globs := &globals.Globals{
Appname: "neoirc", Appname: "neoirc",
@@ -43,19 +38,9 @@ func NewTestServer(
brk: brk, brk: brk,
svc: svc, svc: svc,
conns: make(map[*Conn]struct{}), conns: make(map[*Conn]struct{}),
pingInterval: interval,
pongDeadline: deadline,
resolveHost: resolve,
} }
} }
// ResolveHost is the reverse DNS lookup that a server made by
// New does for each client.
func ResolveHost(ctx context.Context, addr string) string {
return resolveHost(ctx, addr)
}
// Start exposes the unexported start method for tests. // Start exposes the unexported start method for tests.
func (s *Server) Start(addr string) error { func (s *Server) Start(addr string) error {
return s.start(context.Background(), addr) return s.start(context.Background(), addr)
@@ -71,47 +56,19 @@ func (s *Server) Listener() net.Listener {
return s.listener return s.listener
} }
// Service returns the service the server's connections use. // NewTestConn wraps an already-established net.Conn in a
func (s *Server) Service() *service.Service { // Conn so tests can drive connection-level behaviour such
return s.svc // as Disconnect without standing up a whole server.
}
// PingTimes returns how long the server's connections wait
// with nothing read before they are sent a PING, and after it
// before they are closed.
func (s *Server) PingTimes() (time.Duration, time.Duration) {
return s.pingInterval, s.pongDeadline
}
// KillWriteWindow is how long Disconnect allows each write
// to a killed victim.
const KillWriteWindow = killWriteWindow
// PollInterval is how often the relay checks the client
// output queue when no broker notification arrives.
const PollInterval = pollInterval
// PingInterval is how long the server waits with nothing read
// from a connection before it sends a PING.
const PingInterval = pingInterval
// PongDeadline is how long the server waits after the PING
// for a line before it closes the connection.
const PongDeadline = pongDeadline
// StopTimeout is the longest Stop waits for the connections'
// cleanup.
const StopTimeout = stopTimeout
// NewTestConn returns a Conn for nick on tcpConn, without a
// server, database or service behind it.
func NewTestConn( func NewTestConn(
log *slog.Logger, log *slog.Logger,
cfg *config.Config, cfg *config.Config,
tcpConn net.Conn, tcpConn net.Conn,
nick string, nick string,
) *Conn { ) *Conn {
conn := newConn(tcpConn, log, nil, nil, cfg, nil) conn := newConn(
context.Background(), tcpConn, log,
nil, nil, cfg, nil,
)
conn.nick = nick conn.nick = nick
return conn return conn
+143 -378
View File
@@ -4,8 +4,6 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"sneak.berlin/go/neoirc/internal/config"
) )
// TestIntegrationTwoClients is a comprehensive integration // TestIntegrationTwoClients is a comprehensive integration
@@ -779,28 +777,39 @@ func TestIntegrationUserhost(t *testing.T) {
bob := env.dial(t) bob := env.dial(t)
bob.register("bob") bob.register("bob")
bob.send("AWAY :lunch") // Query single nick.
bob.readUntil(func(l string) bool { alice.send("USERHOST bob")
return strings.Contains(l, " 306 ")
aliceReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 302 ")
}) })
assertContains(
t, aliceReply, " 302 ",
"RPL_USERHOST",
)
assertContains(
t, aliceReply, "bob",
"USERHOST contains queried nick",
)
for _, step := range []struct { // Query multiple nicks.
line, want string bob.send("USERHOST alice bob")
}{
{"USERHOST alice", " 302 alice alice=+alice@"},
{"USERHOST alice bob", " :alice=+alice@"},
{"USERHOST alice bob", " bob=-bob@"},
{"USERHOST nobody", " 302 alice :"},
{"USERHOST", " 461 alice USERHOST :"},
} {
alice.send(step.line)
reply := alice.readUntil(func(l string) bool { bobReply := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 302 ") || return strings.Contains(l, " 302 ")
strings.Contains(l, " 461 ") })
}) assertContains(
assertContains(t, reply, step.want, step.line) t, bobReply, " 302 ",
} "RPL_USERHOST multi-nick",
)
assertContains(
t, bobReply, "alice",
"USERHOST multi contains alice",
)
assertContains(
t, bobReply, "bob",
"USERHOST multi contains bob",
)
} }
// TestIntegrationVersion verifies the VERSION command // TestIntegrationVersion verifies the VERSION command
@@ -910,54 +919,17 @@ func TestIntegrationTime(t *testing.T) {
"RPL_TIME", "RPL_TIME",
) )
assertContains( assertContains(
t, aliceReply, testServerName, t, aliceReply, "test.irc",
"TIME reply includes server name", "TIME reply includes server name",
) )
} }
// becomeOper sends OPER with newTestEnvWithOper's // TestIntegrationKill verifies the KILL command: oper can
// credentials and waits for RPL_YOUREOPER. // kill a user, non-oper cannot, and — most importantly —
func (tc *testClient) becomeOper() { // that the victim is actually disconnected rather than
tc.t.Helper() // merely announced as having quit.
//
tc.send("OPER testoper testpass") //nolint:funlen // one KILL scenario asserted end to end
tc.readUntil(func(l string) bool {
return strings.Contains(l, " 381 ")
})
}
// TestIntegrationKillRefused covers the KILL errors: not an
// operator, no such nick, and killing yourself.
func TestIntegrationKillRefused(t *testing.T) {
t.Parallel()
env := newTestEnvWithOper(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
for _, step := range []struct {
line, numeric string
}{
{"KILL bob :nope", " 481 "},
{"OPER testoper testpass", " 381 "},
{"KILL nobody123 :gone", " 401 "},
{"KILL alice :me", " 483 "},
} {
alice.send(step.line)
reply := alice.readUntil(func(l string) bool {
return strings.Contains(l, step.numeric)
})
assertContains(t, reply, step.numeric, step.line)
}
}
// TestIntegrationKill checks that the victim of a KILL is
// told why, is disconnected, and is gone from its channels.
func TestIntegrationKill(t *testing.T) { func TestIntegrationKill(t *testing.T) {
t.Parallel() t.Parallel()
@@ -969,6 +941,7 @@ func TestIntegrationKill(t *testing.T) {
bob := env.dial(t) bob := env.dial(t)
bob.register("bob") bob.register("bob")
// Both join a channel so KILL's QUIT is visible.
alice.joinAndDrain("#killtest") alice.joinAndDrain("#killtest")
bob.joinAndDrain("#killtest") bob.joinAndDrain("#killtest")
@@ -978,9 +951,30 @@ func TestIntegrationKill(t *testing.T) {
strings.Contains(l, "bob") strings.Contains(l, "bob")
}) })
alice.becomeOper() // Non-oper KILL should fail.
alice.send("KILL bob :nope")
aliceKillFail := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 481 ")
})
assertContains(
t, aliceKillFail, " 481 ",
"ERR_NOPRIVILEGES for non-oper KILL",
)
// alice becomes oper.
alice.send("OPER testoper testpass")
alice.readUntil(func(l string) bool {
return strings.Contains(l, " 381 ")
})
// 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() bobLines := bob.readUntilClosed()
assertContains( assertContains(
t, bobLines, "KILL", t, bobLines, "KILL",
@@ -1030,6 +1024,17 @@ func TestIntegrationKill(t *testing.T) {
t, aliceWho, "bob", t, aliceWho, "bob",
"killed user must not appear in WHO", "killed user must not appear in WHO",
) )
// KILL nonexistent nick.
alice.send("KILL nobody123 :gone")
aliceNoSuch := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 401 ")
})
assertContains(
t, aliceNoSuch, " 401 ",
"ERR_NOSUCHNICK for KILL missing target",
)
} }
// TestIntegrationWallops verifies the WALLOPS command: // TestIntegrationWallops verifies the WALLOPS command:
@@ -1056,7 +1061,11 @@ func TestIntegrationWallops(t *testing.T) {
"ERR_NOPRIVILEGES for non-oper WALLOPS", "ERR_NOPRIVILEGES for non-oper WALLOPS",
) )
alice.becomeOper() // alice becomes oper.
alice.send("OPER testoper testpass")
alice.readUntil(func(l string) bool {
return strings.Contains(l, " 381 ")
})
// bob sets +w to receive wallops. // bob sets +w to receive wallops.
bob.send("MODE bob +w") bob.send("MODE bob +w")
@@ -1083,54 +1092,6 @@ func TestIntegrationWallops(t *testing.T) {
) )
} }
// TestIntegrationUserMode covers MODE for a nick: changes
// and queries of your own modes, refusal for another nick,
// even one that differs from yours only in letter case, and
// a rejected mode string changing nothing.
func TestIntegrationUserMode(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
const (
notYours = " 502 alice :Can't change mode for other users"
unknown = " 501 alice :Unknown MODE flag"
)
for _, step := range []struct {
line, want string
}{
{"MODE alice +w", " 221 alice +w"},
{"MODE Alice", notYours},
{"MODE Alice -w", notYours},
{"MODE bob", notYours},
{"MODE bob -w", notYours},
{"MODE alice xw", unknown},
{"MODE alice -w+z", unknown},
{"MODE alice", " 221 alice +w"},
{"MODE alice +w-w", " 221 alice +"},
} {
alice.send(step.line)
reply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 221 ") ||
strings.Contains(l, " 501 ") ||
strings.Contains(l, " 502 ")
})
last := reply[len(reply)-1]
if !strings.HasSuffix(last, step.want) {
t.Errorf("%s: want %q, got %q", step.line, step.want, last)
}
}
}
// TestIntegrationModeSecret tests +s (secret) channel // TestIntegrationModeSecret tests +s (secret) channel
// mode — verifies that +s can be set and the mode is // mode — verifies that +s can be set and the mode is
// reflected in MODE queries. // reflected in MODE queries.
@@ -1241,252 +1202,6 @@ func TestIntegrationModeModerated(t *testing.T) {
) )
} }
// TestIntegrationModeKey checks that JOIN takes the key of
// a +k channel as its second parameter, and that with
// several channels each key goes to the channel in the
// same position.
func TestIntegrationModeKey(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#keyed")
alice.joinAndDrain("#vault")
// The IRC listener cannot set +k, so the test sets the
// keys directly.
for channel, key := range map[string]string{
"#keyed": "sesame",
"#vault": "barley",
} {
chID, err := env.database.GetChannelByName(
t.Context(), channel,
)
if err != nil {
t.Fatalf("get channel %s: %v", channel, err)
}
err = env.database.SetChannelKey(t.Context(), chID, key)
if err != nil {
t.Fatalf("set key of %s: %v", channel, err)
}
}
bob := env.dial(t)
bob.register("bob")
bob.sendAndExpect("JOIN #keyed", " 475 ")
bob.sendAndExpect("JOIN #keyed wrong", " 475 ")
lines := bob.sendAndExpect("JOIN #keyed sesame", " 366 ")
assertContains(
t, lines, "JOIN #keyed", "bob joins with the key",
)
carol := env.dial(t)
carol.register("carol")
lines = carol.sendAndExpect(
"JOIN #keyed,#vault sesame,barley", " 366 carol #vault ",
)
assertContains(t, lines, "JOIN #keyed", "carol joins #keyed")
assertContains(t, lines, "JOIN #vault", "carol joins #vault")
assertNotContains(t, lines, " 475 ", "carol's JOIN")
}
// TestIntegrationModeInviteOnly checks that INVITE lets a
// user into a +i channel.
func TestIntegrationModeInviteOnly(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#invonly")
alice.sendAndExpect("MODE #invonly +i", "+i")
bob := env.dial(t)
bob.register("bob")
bob.sendAndExpect("JOIN #invonly", " 473 ")
alice.sendAndExpect("INVITE bob #invonly", " 341 ")
lines := bob.sendAndExpect("JOIN #invonly", " 366 ")
assertContains(
t, lines, "JOIN #invonly", "bob joins once invited",
)
}
// TestIntegrationModeInviteOnlyNeedsOperator checks that on
// a +i channel an INVITE from a member who is not a channel
// operator gets 482 and does not let the invited user in.
func TestIntegrationModeInviteOnlyNeedsOperator(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#invonly")
// Bob joins before +i is set, so he is a member but
// not an operator.
bob := env.dial(t)
bob.register("bob")
bob.joinAndDrain("#invonly")
alice.sendAndExpect("MODE #invonly +i", "+i")
carol := env.dial(t)
carol.register("carol")
lines := bob.sendAndExpect("INVITE carol #invonly", " 482 ")
assertContains(
t, lines, " 482 bob #invonly ", "bob's INVITE refused",
)
carol.sendAndExpect("JOIN #invonly", " 473 ")
}
// TestIntegrationInviteUserOnChannel checks that inviting a
// user who is already on the channel gets 443.
func TestIntegrationInviteUserOnChannel(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#room")
bob := env.dial(t)
bob.register("bob")
bob.joinAndDrain("#room")
lines := alice.sendAndExpect("INVITE bob #room", " 443 ")
assertContains(
t, lines, " 443 alice bob #room ", "alice's INVITE refused",
)
}
// TestIntegrationInviteLine checks that an IRC listener user
// invited by an IRC listener user, and then over the HTTP
// API, gets an INVITE line from the inviter for the channel
// each time.
func TestIntegrationInviteLine(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#inv")
bob := env.dial(t)
bob.register("bob")
alice.sendAndExpect("INVITE bob #inv", " 341 ")
got := bob.readLine()
want := ":alice!alice@* INVITE bob #inv"
if got != want {
t.Errorf("from the IRC listener: got %q, want %q", got, want)
}
// The HTTP API queues an INVITE with the channel as its body.
env.queueMessage(t, "bob", "INVITE", "carol", "bob", []string{"#inv"})
got = bob.readLine()
want = ":carol!carol@* INVITE bob #inv"
if got != want {
t.Errorf("over the HTTP API: got %q, want %q", got, want)
}
}
// TestIntegrationModeSecretList checks that LIST shows a +s
// channel only to its members.
func TestIntegrationModeSecretList(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#hidden")
alice.joinAndDrain("#visible")
alice.sendAndExpect("MODE #hidden +s", "+s")
bob := env.dial(t)
bob.register("bob")
bobList := bob.sendAndExpect("LIST", " 323 ")
assertContains(
t, bobList, " 322 bob #visible ", "bob's LIST",
)
assertNotContains(t, bobList, "#hidden", "bob's LIST")
aliceList := alice.sendAndExpect("LIST", " 323 ")
assertContains(
t, aliceList, " 322 alice #hidden ", "alice's LIST",
)
}
// TestIntegrationModeSecretWhois checks that WHOIS names a
// +s channel only to users on it.
func TestIntegrationModeSecretWhois(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#hidden")
alice.joinAndDrain("#visible")
alice.sendAndExpect("MODE #hidden +s", "+s")
bob := env.dial(t)
bob.register("bob")
bobWhois := bob.sendAndExpect("WHOIS alice", " 318 ")
assertContains(t, bobWhois, "@#visible", "bob's WHOIS")
assertNotContains(t, bobWhois, "#hidden", "bob's WHOIS")
carol := env.dial(t)
carol.register("carol")
carol.joinAndDrain("#hidden")
carolWhois := carol.sendAndExpect("WHOIS alice", " 318 ")
assertContains(t, carolWhois, "@#hidden", "carol's WHOIS")
}
// TestIntegrationModeHashcash checks that PRIVMSG to a +H
// channel is refused, since an IRC client cannot attach a
// hashcash stamp.
func TestIntegrationModeHashcash(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#stamped")
alice.sendAndExpect("MODE #stamped +H 2", "+H")
lines := alice.sendAndExpect(
"PRIVMSG #stamped :hello", " 404 ",
)
assertContains(
t, lines, " 404 alice #stamped :Channel requires hashcash",
"PRIVMSG refused",
)
}
// TestIntegrationThirdClientObserver verifies that a third // TestIntegrationThirdClientObserver verifies that a third
// client observing the same channel receives messages from // client observing the same channel receives messages from
// the other two. // the other two.
@@ -1531,29 +1246,79 @@ func TestIntegrationThirdClientObserver(t *testing.T) {
) )
} }
// TestIntegrationDefaultServerNameFallback checks that with // assertNoEmptyParam fails if any line contains a doubled
// SERVER_NAME unset, as it is by default, VERSION, ADMIN and // space, which is what FormatMessage emits for an empty
// TIME name the server "neoirc". // non-trailing parameter. A numeric whose server-name
// parameter came out empty is malformed on the wire even
// though it still contains the numeric code, so the other
// assertions in this file would not catch it.
func assertNoEmptyParam(
t *testing.T,
lines []string,
context string,
) {
t.Helper()
for _, line := range lines {
if strings.Contains(line, " ") {
t.Errorf(
"%s: empty parameter in wire line: %q",
context, line,
)
}
}
}
// TestIntegrationDefaultServerNameFallback runs the wire
// server with SERVER_NAME unset, which is the shipped
// default from config.go, and verifies that VERSION, ADMIN
// and TIME fall back to "neoirc" exactly as the HTTP path
// does instead of emitting an empty server-name parameter.
//
// Every other wire test hardcodes ServerName: "test.irc",
// so none of them exercise the default configuration.
func TestIntegrationDefaultServerNameFallback(t *testing.T) { func TestIntegrationDefaultServerNameFallback(t *testing.T) {
t.Parallel() t.Parallel()
env := newTestEnvWithConfig(t, &config.Config{}) env := newTestEnvWithServerName(t, "")
alice := env.dial(t) alice := env.dial(t)
alice.register("alice") alice.register("alice")
for _, step := range []struct { alice.send("VERSION")
line, lastNumeric, want string
}{
{"VERSION", " 351 ", " 351 alice neoirc-test. neoirc "},
{"ADMIN", " 259 ", " 256 alice neoirc "},
{"TIME", " 391 ", " 391 alice neoirc "},
} {
alice.send(step.line)
reply := alice.readUntil(func(l string) bool { versionReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, step.lastNumeric) return strings.Contains(l, " 351 ")
}) })
assertContains(t, reply, step.want, step.line) assertNoEmptyParam(t, versionReply, "VERSION")
} assertContains(
t, versionReply, "neoirc",
"VERSION falls back to default server name",
)
alice.send("ADMIN")
adminReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 259 ")
})
assertNoEmptyParam(t, adminReply, "ADMIN")
assertContains(
t, adminReply, " 256 alice neoirc ",
"RPL_ADMINME names the default server",
)
assertContains(
t, adminReply, "admin@neoirc",
"RPL_ADMINEMAIL is a well-formed address",
)
alice.send("TIME")
timeReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 391 ")
})
assertNoEmptyParam(t, timeReply, "TIME")
assertContains(
t, timeReply, " 391 alice neoirc ",
"RPL_TIME names the default server",
)
} }
+1 -8
View File
@@ -90,10 +90,7 @@ func ParseMessage(line string) *Message {
} }
// FormatMessage formats an IRC message into wire protocol // FormatMessage formats an IRC message into wire protocol
// format (without the trailing CR-LF). CR, LF and NUL are // format (without the trailing CR-LF).
// removed from each parameter, because on the wire they would
// end the line early; this happens before deciding whether
// the last parameter needs a leading ':'.
func FormatMessage( func FormatMessage(
prefix, command string, prefix, command string,
params ...string, params ...string,
@@ -109,10 +106,6 @@ func FormatMessage(
buf.WriteString(command) buf.WriteString(command)
for i, param := range params { for i, param := range params {
param = strings.ReplaceAll(param, "\r", "")
param = strings.ReplaceAll(param, "\n", "")
param = strings.ReplaceAll(param, "\x00", "")
buf.WriteByte(' ') buf.WriteByte(' ')
isLast := i == len(params)-1 isLast := i == len(params)-1
+62 -58
View File
@@ -3,7 +3,6 @@ package ircserver
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"slices"
"strings" "strings"
"time" "time"
@@ -27,30 +26,22 @@ func (c *Conn) relayMessages(ctx context.Context) {
default: default:
} }
// Register with the broker before draining, so that a
// message queued after the drain has read the queue
// still signals waitCh.
waitCh := c.brk.Wait(c.sessionID)
// Drain any available messages. // Drain any available messages.
delivered := c.drainQueue(ctx) delivered := c.drainQueue(ctx)
if delivered { if delivered {
c.brk.Remove(c.sessionID, waitCh)
// Tight loop while there are messages. // Tight loop while there are messages.
continue continue
} }
// Wait for notification or timeout. // Wait for notification or timeout.
waitCh := c.brk.Wait(c.sessionID)
select { select {
case <-waitCh: case <-waitCh:
// New message notification — loop back. // New message notification — loop back.
case <-ticker.C: case <-ticker.C:
// Periodic check. Nothing used waitCh, so take it // Periodic check.
// out of the broker before the next pass adds
// another.
c.brk.Remove(c.sessionID, waitCh)
case <-ctx.Done(): case <-ctx.Done():
c.brk.Remove(c.sessionID, waitCh) c.brk.Remove(c.sessionID, waitCh)
@@ -92,7 +83,7 @@ func (c *Conn) drainQueue(ctx context.Context) bool {
// //
//nolint:cyclop // dispatch table //nolint:cyclop // dispatch table
func (c *Conn) deliverIRCMessage( func (c *Conn) deliverIRCMessage(
ctx context.Context, _ context.Context,
msg *db.IRCMessage, msg *db.IRCMessage,
) { ) {
command := msg.Command command := msg.Command
@@ -114,13 +105,13 @@ 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, bodyLines) c.deliverTextMessage(msg, command, text)
case command == irc.CmdJoin: case command == irc.CmdJoin:
c.deliverJoin(msg) c.deliverJoin(msg)
case command == irc.CmdPart: case command == irc.CmdPart:
c.deliverPart(msg, text) c.deliverPart(msg, text)
case command == irc.CmdNick: case command == irc.CmdNick:
c.deliverNickChange(ctx, msg, text) c.deliverNickChange(msg, text)
case command == irc.CmdQuit: case command == irc.CmdQuit:
c.deliverQuitMsg(msg, text) c.deliverQuitMsg(msg, text)
case command == irc.CmdTopic: case command == irc.CmdTopic:
@@ -132,7 +123,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, bodyLines) c.deliverMode(msg, text)
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)
@@ -188,28 +179,43 @@ func (c *Conn) deliverNumeric(
c.send(FormatMessage(from, msg.Command, allParams...)) c.send(FormatMessage(from, msg.Command, allParams...))
} }
// deliverTextMessage sends PRIVMSG or NOTICE, one line for // deliverTextMessage sends PRIVMSG or NOTICE.
// each line of the body.
func (c *Conn) deliverTextMessage( func (c *Conn) deliverTextMessage(
msg *db.IRCMessage, msg *db.IRCMessage,
command string, command, text string,
bodyLines []string,
) { ) {
from := msg.From from := msg.From
target := msg.To target := msg.To
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
// Don't echo our own messages back.
if strings.EqualFold(from, nick) {
return
}
prefix := from prefix := from
if !strings.Contains(prefix, "!") { if !strings.Contains(prefix, "!") {
prefix = from + "!" + from + "@*" prefix = from + "!" + from + "@*"
} }
for _, line := range bodyLines { c.send(FormatMessage(prefix, command, target, text))
c.send(FormatMessage(prefix, command, target, line))
}
} }
// deliverJoin sends a JOIN notification. // deliverJoin sends a JOIN notification.
func (c *Conn) deliverJoin(msg *db.IRCMessage) { func (c *Conn) deliverJoin(msg *db.IRCMessage) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
// Don't echo our own JOINs (we already sent them
// during joinChannel).
if strings.EqualFold(msg.From, nick) {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
channel := msg.To channel := msg.To
@@ -218,6 +224,14 @@ func (c *Conn) deliverJoin(msg *db.IRCMessage) {
// deliverPart sends a PART notification. // deliverPart sends a PART notification.
func (c *Conn) deliverPart(msg *db.IRCMessage, text string) { func (c *Conn) deliverPart(msg *db.IRCMessage, text string) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if strings.EqualFold(msg.From, nick) {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
channel := msg.To channel := msg.To
@@ -230,17 +244,18 @@ func (c *Conn) deliverPart(msg *db.IRCMessage, text string) {
} }
} }
// deliverNickChange sends a NICK change notification. The // deliverNickChange sends a NICK change notification.
// change may be its own session's, made from another client,
// so the connection first takes the nick its session holds
// now. It does not go by the line's old nick: the connection
// may have taken that nick after another user gave it up.
func (c *Conn) deliverNickChange( func (c *Conn) deliverNickChange(
ctx context.Context,
msg *db.IRCMessage, msg *db.IRCMessage,
newNick string, newNick string,
) { ) {
c.loadSessionNick(ctx) c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if strings.EqualFold(msg.From, nick) {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
@@ -252,6 +267,14 @@ func (c *Conn) deliverQuitMsg(
msg *db.IRCMessage, msg *db.IRCMessage,
text string, text string,
) { ) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if strings.EqualFold(msg.From, nick) {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
if text != "" { if text != "" {
@@ -304,24 +327,16 @@ func (c *Conn) deliverKickMsg(
} }
} }
// deliverInviteMsg sends an INVITE line from the inviter // deliverInviteMsg sends an INVITE notification.
// for the channel named in text, or nothing when text names
// no channel.
func (c *Conn) deliverInviteMsg( func (c *Conn) deliverInviteMsg(
msg *db.IRCMessage, _ *db.IRCMessage,
text string, text string,
) { ) {
if text == "" {
return
}
c.mu.Lock() c.mu.Lock()
nick := c.nick nick := c.nick
c.mu.Unlock() c.mu.Unlock()
prefix := msg.From + "!" + msg.From + "@*" c.sendFromServer("NOTICE", nick, text)
c.send(FormatMessage(prefix, "INVITE", nick, text))
} }
// deliverWallops sends a WALLOPS notification. // deliverWallops sends a WALLOPS notification.
@@ -336,26 +351,15 @@ func (c *Conn) deliverWallops(
)) ))
} }
// deliverMode sends a MODE change notification. The first // deliverMode sends a MODE change notification.
// 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,
bodyLines []string, text string,
) { ) {
if len(bodyLines) == 0 || bodyLines[0] == "" {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
target := msg.To
params := slices.Concat( if text != "" {
[]string{msg.To}, c.send(FormatMessage(prefix, "MODE", target, text))
strings.Fields(bodyLines[0]), }
bodyLines[1:],
)
c.send(FormatMessage(prefix, "MODE", params...))
} }
-697
View File
@@ -1,697 +0,0 @@
package ircserver_test
import (
"context"
"encoding/json"
"slices"
"strings"
"testing"
"time"
"sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/service"
)
// 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, 0, 0, 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, 0)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
env.brk.Notify(sessionID)
}
// TestRelayKeepsOneWaitChannel checks that an IRC listener
// connection holds at most one broker wait channel across
// several relay ticks, both while idle and after the relay
// has delivered it a message, and none once it closes.
func TestRelayKeepsOneWaitChannel(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("idle")
sessionID, err := env.database.GetSessionByNick(t.Context(), "idle")
if err != nil {
t.Fatalf("get session of idle: %v", err)
}
env.checkOneWaitChannel(t, sessionID)
env.queueMessage(
t, "idle", "PRIVMSG", "carol", "idle", []string{"hello"},
)
client.readUntil(func(l string) bool {
return strings.Contains(l, "hello")
})
env.checkOneWaitChannel(t, sessionID)
err = client.conn.Close()
if err != nil {
t.Fatalf("close: %v", err)
}
deadline := time.Now().Add(testTimeout)
for env.brk.ListenerCount(sessionID) != 0 {
if time.Now().After(deadline) {
t.Fatalf("after close: got %d wait channels, want 0",
env.brk.ListenerCount(sessionID))
}
time.Sleep(relayCheckStep)
}
}
// checkOneWaitChannel fails the test if the session
// sessionID holds more than one broker wait channel at any of
// several relay ticks.
func (env *testEnv) checkOneWaitChannel(
t *testing.T,
sessionID int64,
) {
t.Helper()
const ticks = 5
for tick := range ticks {
time.Sleep(ircserver.PollInterval)
got := env.brk.ListenerCount(sessionID)
if got > 1 {
t.Fatalf("tick %d: got %d wait channels, want at most 1",
tick+1, got)
}
}
}
// 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)
}
}
}
// TestRelayInviteWithoutChannel checks that an INVITE whose
// body names no channel sends the invited IRC listener user
// no INVITE line.
func TestRelayInviteWithoutChannel(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
bob := env.dial(t)
bob.register("bob")
// A nil body is stored as JSON null, which is not an array.
for _, body := range [][]string{nil, {}, {""}} {
env.queueMessage(t, "bob", "INVITE", "carol", "bob", body)
}
// The relay sends a user's queue in order, so this arrives
// after anything sent for the INVITEs above.
env.queueMessage(t, "bob", "PRIVMSG", "carol", "bob",
[]string{"the end"})
lines := bob.readUntil(func(l string) bool {
return strings.HasSuffix(l, " :the end")
})
for _, line := range lines {
if strings.Contains(line, " INVITE ") {
t.Errorf("got %q, want no INVITE line", line)
}
}
}
// TestRelayLineBreaks checks that a CR, LF or NUL in a stored
// body line, key or mask never reaches an IRC listener user
// as a line break, so no extra line arrives, and that a body
// line keeps its parameter and leading ':' once they are
// removed.
func TestRelayLineBreaks(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
bob := env.dial(t)
bob.register("bob")
env.queueMessage(t, "bob", "PRIVMSG", "carol", "bob", []string{
"one\r\n:admin!a@b PRIVMSG bob :hi",
"two\n:admin!a@b NOTICE bob :hi\x00",
})
env.queueMessage(t, "bob", "MODE", "op", "#breaks",
[]string{"+k", "key\r\nERROR :Closing link"})
env.queueMessage(t, "bob", "MODE", "op", "#breaks",
[]string{"+b", "*!*@*\r\n:admin!a@b KICK #breaks bob"})
env.queueMessage(t, "bob", "PRIVMSG", "carol", "bob",
[]string{"\r\n", "\x00:hi"})
env.queueMessage(t, "bob", "PRIVMSG", "carol", "bob",
[]string{"the end"})
lines := bob.readUntil(func(l string) bool {
return strings.HasSuffix(l, " :the end")
})
want := []string{
":carol!carol@* PRIVMSG bob :one:admin!a@b PRIVMSG bob :hi",
":carol!carol@* PRIVMSG bob :two:admin!a@b NOTICE bob :hi",
":op!op@* MODE #breaks +k :keyERROR :Closing link",
":op!op@* MODE #breaks +b :*!*@*:admin!a@b KICK #breaks bob",
":carol!carol@* PRIVMSG bob :",
":carol!carol@* PRIVMSG bob ::hi",
":carol!carol@* PRIVMSG bob :the end",
}
got := lines[max(0, len(lines)-len(want)):]
if !slices.Equal(got, want) {
t.Errorf("got %q, want %q", got, want)
}
}
// TestRelayNickDiffersOnlyInCase checks that an IRC listener
// user alice receives a JOIN, PRIVMSG, PART, NICK and QUIT
// from Alice in a channel they share: nicks are
// case-sensitive, so Alice is someone else.
func TestRelayNickDiffersOnlyInCase(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#case")
capitalAlice := env.dial(t)
capitalAlice.register("Alice")
for _, step := range []struct {
line, want string
}{
{"JOIN #case", ":Alice!Alice@* JOIN #case"},
{"PRIVMSG #case :hi there", ":Alice!Alice@* PRIVMSG #case :hi there"},
{"PART #case :see you", ":Alice!Alice@* PART #case :see you"},
{"JOIN #case", ":Alice!Alice@* JOIN #case"},
{"NICK Alicia", ":Alice!Alice@* NICK Alicia"},
{"NICK Alice", ":Alicia!Alicia@* NICK Alice"}, // back for QUIT
{"QUIT", ":Alice!Alice@* QUIT :Quit: Connection closed"},
} {
capitalAlice.send(step.line)
got := alice.readLine()
if got != step.want {
t.Errorf("%s: got %q, want %q", step.line, got, step.want)
}
}
}
// TestRelayOwnChangeOnce checks that an IRC listener user who
// sets a topic, changes a channel mode or kicks gets one TOPIC,
// MODE or KICK line for it, and so does the other member.
func TestRelayOwnChangeOnce(t *testing.T) {
t.Parallel()
for _, step := range []struct {
command, line string
}{
{"TOPIC", "TOPIC #dup :new topic"},
{"MODE", "MODE #dup +m"},
{"KICK", "KICK #dup carl :bye"},
} {
t.Run(step.command, func(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#dup")
carl := env.dial(t)
carl.register("carl")
carl.joinAndDrain("#dup")
alice.send(step.line)
// The reply to alice's next command comes after her
// command has finished, so her own copy comes before it.
beforePong := alice.sendAndExpect("PING :done", "PONG")
// The relay sends a user's queue in order, so a message
// queued now arrives after any relayed copy.
for nick, client := range map[string]*testClient{
"alice": alice, "carl": carl,
} {
env.queueMessage(
t, nick, "PRIVMSG", "dave", nick,
[]string{"after the change"},
)
lines := client.readUntil(func(l string) bool {
return strings.HasSuffix(l, " :after the change")
})
if client == alice {
lines = slices.Concat(beforePong, lines)
}
var changes []string
for _, line := range lines {
if strings.Contains(line, " "+step.command+" #dup ") {
changes = append(changes, line)
}
}
if len(changes) != 1 {
t.Errorf("%s got %q, want one %s line",
nick, changes, step.command)
}
}
})
}
}
// TestRelayOwnSessionOnce checks that a command a user makes
// from one client of their session reaches each of the
// session's clients once: an IRC listener connection and an
// HTTP API client. The connection that sent a command gets
// only its own echo, and none for PRIVMSG and NOTICE, which
// IRC does not echo.
//
//nolint:funlen // table-driven test
func TestRelayOwnSessionOnce(t *testing.T) {
t.Parallel()
hello := json.RawMessage(`["hello"]`)
for _, step := range []struct {
// line is the command as alice sends it on the IRC
// listener.
line string
// api makes the same command for her session the way
// the HTTP API does.
api func(
ctx context.Context, svc *service.Service, sessionID int64,
) error
// echoes is how many copies alice's connection gets of
// line when it sends line itself.
echoes int
}{
{"PRIVMSG #chan :hello", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
_, err := svc.SendChannelMessage(
ctx, sessionID, 0, "alice", "PRIVMSG", "#chan", hello, nil,
)
return err
}, 0},
{"NOTICE bob :hello", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
_, err := svc.SendDirectMessage(
ctx, sessionID, 0, "alice", "NOTICE", "bob", hello, nil,
)
return err
}, 0},
{"JOIN #other", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
_, err := svc.JoinChannel(ctx, sessionID, 0, "alice", "#other", "")
return err
}, 1},
{"PART #chan :bye", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
return svc.PartChannel(ctx, sessionID, 0, "alice", "#chan", "bye")
}, 1},
{"NICK alice2", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
return svc.ChangeNick(ctx, sessionID, 0, "alice", "alice2")
}, 1},
{"TOPIC #chan :new topic", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
return svc.SetTopic(
ctx, sessionID, 0, "alice", "#chan", "new topic",
)
}, 1},
{"MODE #chan +m", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
chID, err := svc.ValidateChannelOp(ctx, sessionID, "#chan")
if err != nil {
return err
}
err = svc.SetChannelFlag(ctx, chID, 'm', true)
if err != nil {
return err
}
svc.BroadcastMode(ctx, 0, "alice", "#chan", chID, "+m")
return nil
}, 1},
{"KICK #chan bob :bye", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
return svc.KickUser(
ctx, sessionID, 0, "alice", "#chan", "bob", "bye",
)
}, 1},
} {
command := strings.Fields(step.line)[0]
t.Run(command+" from IRC", func(t *testing.T) {
t.Parallel()
ircCopies, httpCopies := ownSessionCopies(t, step.line, nil)
if ircCopies != step.echoes || httpCopies != 1 {
t.Errorf("connection got %d, HTTP client got %d, want %d and 1",
ircCopies, httpCopies, step.echoes)
}
})
t.Run(command+" from the HTTP API", func(t *testing.T) {
t.Parallel()
ircCopies, httpCopies := ownSessionCopies(t, step.line, step.api)
if ircCopies != 1 || httpCopies != 1 {
t.Errorf("connection got %d, HTTP client got %d, want 1 and 1",
ircCopies, httpCopies)
}
})
}
}
// ownSessionCopies sets up alice with aliceWithHTTPClient. It
// makes the command line from her connection, or with api from
// the HTTP API client when api is not nil, and returns how many
// copies of it her connection and her HTTP API client got.
func ownSessionCopies(
t *testing.T,
line string,
api func(ctx context.Context, svc *service.Service, sessionID int64) error,
) (int, int) {
t.Helper()
ctx := t.Context()
env := newTestEnv(t)
alice, _, sessionID, httpClientID := aliceWithHTTPClient(t, env)
var lines []string
if api == nil {
alice.send(line)
// Her echo comes before the reply to her next command.
lines = alice.sendAndExpect("PING :done", "PONG")
} else {
err := api(ctx, env.srv.Service(), sessionID)
if err != nil {
t.Fatalf("%s over the HTTP API: %v", line, err)
}
}
msgs, _, err := env.database.PollMessages(ctx, httpClientID, 0, 0)
if err != nil {
t.Fatalf("poll the HTTP API client: %v", err)
}
httpCopies := 0
for _, msg := range msgs {
if msg.Command == strings.Fields(line)[0] {
httpCopies++
}
}
// The relay sends a queue in order, so this arrives after any
// copy. It is queued by session, as NICK changes her nick.
endID, _, err := env.database.InsertMessage(
ctx, "PRIVMSG", "dave", "alice", 0, 0,
nil, json.RawMessage(`["the end"]`), nil,
)
if err != nil {
t.Fatalf("insert message: %v", err)
}
err = env.database.EnqueueToSession(ctx, sessionID, endID, 0)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
env.brk.Notify(sessionID)
lines = append(lines, alice.readUntil(func(l string) bool {
return strings.HasSuffix(l, " :the end")
})...)
// A copy starts with the command and its first parameter.
start := " " + strings.Join(strings.Fields(line)[:2], " ")
ircCopies := strings.Count(strings.Join(lines, "\n"), start)
return ircCopies, httpCopies
}
// aliceWithHTTPClient connects alice to the IRC listener and
// has her join #chan, which bob is on too, then adds an HTTP API
// client to her session with the password her connection set,
// as POST /api/v1/login does. It returns her connection, bob's
// connection, her session and her HTTP API client.
func aliceWithHTTPClient(
t *testing.T, env *testEnv,
) (*testClient, *testClient, int64, int64) {
t.Helper()
const password = "alicepass"
alice := env.dial(t)
alice.send("PASS " + password)
alice.register("alice")
// Registration stores the password before the JOIN runs.
alice.joinAndDrain("#chan")
bob := env.dial(t)
bob.register("bob")
bob.joinAndDrain("#chan")
sessionID, httpClientID, _, err := env.database.LoginUser(
t.Context(), "alice", password, "127.0.0.1", "localhost",
)
if err != nil {
t.Fatalf("log in as alice: %v", err)
}
return alice, bob, sessionID, httpClientID
}
// TestRelayNickChangeFromHTTPAPI checks that once alice's nick
// is changed from her HTTP API client, her IRC listener
// connection uses the new nick: what she then sends from it
// reaches bob under the new nick.
func TestRelayNickChangeFromHTTPAPI(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice, bob, sessionID, _ := aliceWithHTTPClient(t, env)
bob.joinAndDrain("#other")
// As the HTTP API does.
err := env.srv.Service().ChangeNick(
t.Context(), sessionID, 0, "alice", "alice2",
)
if err != nil {
t.Fatalf("change nick over the HTTP API: %v", err)
}
// Her connection has taken the new nick once it has sent her
// the change.
const change = ":alice!alice@* NICK alice2"
alice.readUntil(func(l string) bool { return l == change })
bob.readUntil(func(l string) bool { return l == change })
for _, step := range []struct {
line, want string
}{
{"PRIVMSG #chan :hi there", ":alice2!alice2@* PRIVMSG #chan :hi there"},
{"JOIN #other", ":alice2!alice2@* JOIN #other"},
{"NICK alice3", ":alice2!alice2@* NICK alice3"},
} {
alice.send(step.line)
got := bob.readLine()
if got != step.want {
t.Errorf("%s: got %q, want %q", step.line, got, step.want)
}
}
}
// TestRelayNickChangeKeepsTakenNick checks that an IRC
// listener connection which takes a nick another user has
// given up keeps it when that user's change reaches it
// afterwards: what it then sends goes out under the nick its
// session holds.
func TestRelayNickChangeKeepsTakenNick(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
bob := env.dial(t)
bob.register("bob")
bob.sendAndExpect("NICK bob2", " NICK bob2")
alice := env.dial(t)
alice.register("alice")
alice.sendAndExpect("NICK bob", " NICK bob")
alice.joinAndDrain("#chan")
bob.joinAndDrain("#chan")
// Bob's change reaches alice only now, as it does when she
// takes his old nick before her connection has passed the
// change on. She holds bob now, so it is queued to bob.
env.queueMessage(t, "bob", "NICK", "bob", "", []string{"bob2"})
alice.readUntil(func(l string) bool {
return l == ":bob!bob@* NICK bob2"
})
alice.send("PRIVMSG #chan :hi there")
const want = ":bob!bob@* PRIVMSG #chan :hi there"
got := bob.readLine()
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
+3 -62
View File
@@ -6,7 +6,6 @@ import (
"log/slog" "log/slog"
"net" "net"
"sync" "sync"
"time"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/broker"
@@ -16,10 +15,6 @@ import (
"sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/service"
) )
// stopTimeout is the longest stop waits for the connections'
// cleanup, which ends their sessions.
const stopTimeout = 5 * time.Second
// Params defines the dependencies for creating an IRC // Params defines the dependencies for creating an IRC
// Server. // Server.
type Params struct { type Params struct {
@@ -43,19 +38,6 @@ type Server struct {
mu sync.Mutex mu sync.Mutex
conns map[*Conn]struct{} conns map[*Conn]struct{}
cancel context.CancelFunc cancel context.CancelFunc
// serving counts the connections whose serve, cleanup
// included, has not returned yet.
serving sync.WaitGroup
// pingInterval and pongDeadline are the constants of the
// same names, except in tests, which shorten them.
pingInterval time.Duration
pongDeadline time.Duration
// resolveHost is the function of the same name, except in
// tests, which replace it with one that blocks.
resolveHost func(ctx context.Context, addr string) string
} }
// New creates a new IRC Server and registers its lifecycle // New creates a new IRC Server and registers its lifecycle
@@ -75,11 +57,6 @@ func New(
listener: nil, listener: nil,
cancel: nil, cancel: nil,
mu: sync.Mutex{}, mu: sync.Mutex{},
serving: sync.WaitGroup{},
pingInterval: pingInterval,
pongDeadline: pongDeadline,
resolveHost: resolveHost,
} }
listenAddr := params.Config.IRCListenAddr listenAddr := params.Config.IRCListenAddr
@@ -126,10 +103,7 @@ func (s *Server) start(ctx context.Context, addr string) error {
return nil return nil
} }
// stop shuts down the listener and all connections, then // stop shuts down the listener and all connections.
// waits up to stopTimeout for every connection's cleanup to
// finish, so that their sessions are ended before the
// database closes.
func (s *Server) stop() { func (s *Server) stop() {
if s.cancel != nil { if s.cancel != nil {
s.cancel() s.cancel()
@@ -144,22 +118,6 @@ func (s *Server) stop() {
c.conn.Close() //nolint:errcheck,gosec c.conn.Close() //nolint:errcheck,gosec
} }
s.mu.Unlock() s.mu.Unlock()
done := make(chan struct{})
go func() {
s.serving.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(stopTimeout):
s.log.Error(
"irc connections not cleaned up before stop timeout",
"timeout", stopTimeout,
)
}
} }
// acceptLoop accepts new connections. // acceptLoop accepts new connections.
@@ -180,39 +138,22 @@ func (s *Server) acceptLoop(ctx context.Context) {
} }
client := newConn( client := newConn(
tcpConn, s.log, ctx, tcpConn, s.log,
s.database, s.brk, s.cfg, s.svc, s.database, s.brk, s.cfg, s.svc,
) )
s.mu.Lock() s.mu.Lock()
// stop cancels ctx before it takes s.mu to close the
// connections, so a connection accepted while stop
// runs is either closed and waited for by stop or
// closed here.
if ctx.Err() != nil {
s.mu.Unlock()
tcpConn.Close() //nolint:errcheck,gosec
return
}
s.conns[client] = struct{}{} s.conns[client] = struct{}{}
s.serving.Add(1)
s.mu.Unlock() s.mu.Unlock()
go func() { go func() {
defer s.serving.Done()
defer func() { defer func() {
s.mu.Lock() s.mu.Lock()
delete(s.conns, client) delete(s.conns, client)
s.mu.Unlock() s.mu.Unlock()
}() }()
client.serve( client.serve(ctx)
ctx, s.resolveHost,
s.pingInterval, s.pongDeadline,
)
}() }()
} }
} }
+112 -710
View File
@@ -2,27 +2,20 @@ package ircserver_test
import ( import (
"bufio" "bufio"
"context"
"crypto/rand"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
"os" "os"
"runtime" "runtime"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"time" "time"
"go.uber.org/fx/fxtest"
"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/ircserver" "sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/logger"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -43,75 +36,26 @@ type testEnv struct {
srv *ircserver.Server srv *ircserver.Server
} }
// testServerName is the SERVER_NAME of newTestEnv.
const testServerName = "test.irc"
func newTestEnv(t *testing.T) *testEnv { func newTestEnv(t *testing.T) *testEnv {
t.Helper() t.Helper()
return newTestEnvWithConfig(t, &config.Config{ return newTestEnvWithServerName(t, "test.irc")
ServerName: testServerName,
MOTD: "Welcome to test IRC",
})
} }
// newTestEnvWithOper is newTestEnv with the operator name // newTestEnvWithServerName creates a test environment with
// testoper and password testpass. // an explicit SERVER_NAME. Passing "" exercises the shipped
func newTestEnvWithOper(t *testing.T) *testEnv { // default from config.go, under which the server must fall
t.Helper() // back to "neoirc" rather than emitting an empty
// server-name parameter.
return newTestEnvWithConfig(t, &config.Config{ func newTestEnvWithServerName(
ServerName: testServerName,
MOTD: "Welcome to test IRC",
OperName: "testoper",
OperPassword: "testpass",
})
}
// newTestEnvWithConfig starts an IRC server with cfg on a
// fresh database.
func newTestEnvWithConfig(
t *testing.T, t *testing.T,
cfg *config.Config, serverName string,
) *testEnv { ) *testEnv {
t.Helper() t.Helper()
return newTestEnvWithPingTimes(
t, cfg, ircserver.PingInterval, ircserver.PongDeadline,
)
}
// newTestEnvWithPingTimes is newTestEnvWithConfig, except
// that the server sends a connection a PING after interval
// with nothing read, and closes it when nothing is read
// within deadline after that.
func newTestEnvWithPingTimes(
t *testing.T,
cfg *config.Config,
interval, deadline time.Duration,
) *testEnv {
t.Helper()
return newTestEnvWithResolver(
t, cfg, interval, deadline, ircserver.ResolveHost,
)
}
// newTestEnvWithResolver is newTestEnvWithPingTimes, except
// that the server looks up a client's hostname with resolve.
func newTestEnvWithResolver(
t *testing.T,
cfg *config.Config,
interval, deadline time.Duration,
resolve func(ctx context.Context, addr string) string,
) *testEnv {
t.Helper()
// A random name, so that no other test environment, not
// even an earlier run of the same test, can share it.
dsn := fmt.Sprintf( dsn := fmt.Sprintf(
"file:%s?mode=memory&cache=shared", "file:%s?mode=memory&cache=shared&_journal_mode=WAL",
rand.Text(), t.Name(),
) )
conn, err := sql.Open("sqlite", dsn) conn, err := sql.Open("sqlite", dsn)
@@ -119,13 +63,6 @@ func newTestEnvWithResolver(
t.Fatalf("open db: %v", err) t.Fatalf("open db: %v", err)
} }
t.Cleanup(func() {
err := conn.Close()
if err != nil {
t.Logf("close db: %v", err)
}
})
conn.SetMaxOpenConns(1) conn.SetMaxOpenConns(1)
_, err = conn.ExecContext( _, err = conn.ExecContext(
@@ -144,6 +81,11 @@ func newTestEnvWithResolver(
brk := broker.New() brk := broker.New()
cfg := &config.Config{ //nolint:exhaustruct
ServerName: serverName,
MOTD: "Welcome to test IRC",
}
var listenConfig net.ListenConfig var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0") listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
@@ -163,18 +105,102 @@ func newTestEnvWithResolver(
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct &slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
)) ))
srv := ircserver.NewTestServer( srv := ircserver.NewTestServer(log, cfg, database, brk)
log, cfg, database, brk, interval, deadline, resolve,
)
err = srv.Start(addr) err = srv.Start(addr)
if err != nil { if err != nil {
t.Fatalf("start irc server: %v", err) t.Fatalf("start irc server: %v", err)
} }
// Cleanups run last registered first, so the server stops t.Cleanup(func() {
// before its database is closed. srv.Stop()
t.Cleanup(srv.Stop)
err := conn.Close()
if err != nil {
t.Logf("close db: %v", err)
}
})
return &testEnv{
database: database,
brk: brk,
cfg: cfg,
srv: srv,
}
}
// newTestEnvWithOper creates a test environment with oper
// credentials configured.
func newTestEnvWithOper(t *testing.T) *testEnv {
t.Helper()
dsn := fmt.Sprintf(
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
t.Name(),
)
conn, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("open db: %v", err)
}
conn.SetMaxOpenConns(1)
_, err = conn.ExecContext(
t.Context(), "PRAGMA foreign_keys = ON",
)
if err != nil {
t.Fatalf("pragma: %v", err)
}
database := db.NewTestDatabaseFromConn(conn)
err = database.RunMigrations(t.Context())
if err != nil {
t.Fatalf("migrate: %v", err)
}
brk := broker.New()
cfg := &config.Config{ //nolint:exhaustruct
ServerName: "test.irc",
MOTD: "Welcome to test IRC",
OperName: "testoper",
OperPassword: "testpass",
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := listener.Addr().String()
err = listener.Close()
if err != nil {
t.Fatalf("close listener: %v", err)
}
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
))
srv := ircserver.NewTestServer(log, cfg, database, brk)
err = srv.Start(addr)
if err != nil {
t.Fatalf("start irc server: %v", err)
}
t.Cleanup(func() {
srv.Stop()
err := conn.Close()
if err != nil {
t.Logf("close db: %v", err)
}
})
return &testEnv{ return &testEnv{
database: database, database: database,
@@ -213,34 +239,6 @@ func (env *testEnv) dial(t *testing.T) *testClient {
} }
} }
// The ping interval and PONG deadline for
// newTestEnvWithShortPings. They differ, so that a test can
// tell them apart; the PONG deadline is how long a test
// client has to answer a PING.
const (
testPingInterval = time.Second
testPongDeadline = 300 * time.Millisecond
)
// newTestEnvWithShortPings starts an IRC server that uses
// testPingInterval and testPongDeadline in place of
// pingInterval and pongDeadline.
func newTestEnvWithShortPings(t *testing.T) *testEnv {
t.Helper()
return newTestEnvWithPingTimes(
t,
&config.Config{ServerName: testServerName},
testPingInterval, testPongDeadline,
)
}
// isServerPing reports whether line is the PING the test
// server sends a quiet client.
func isServerPing(line string) bool {
return line == "PING :"+testServerName
}
// testClient wraps a raw TCP connection with helpers. // testClient wraps a raw TCP connection with helpers.
type testClient struct { type testClient struct {
t *testing.T t *testing.T
@@ -312,9 +310,11 @@ func (tc *testClient) register(nick string) []string {
}) })
} }
// readUntilClosed returns the lines received until the // readUntilClosed reads until the peer closes the
// server closes the connection, and fails the test if it is // connection, returning the lines received first. It
// still open after testTimeout. // 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 { func (tc *testClient) readUntilClosed() []string {
tc.t.Helper() tc.t.Helper()
@@ -331,7 +331,8 @@ func (tc *testClient) readUntilClosed() []string {
err := tc.scanner.Err() err := tc.scanner.Err()
if err != nil { if err != nil {
tc.t.Fatalf( tc.t.Fatalf(
"connection not closed: %v (lines: %v)", "expected EOF on victim socket, got %v "+
"(lines: %v)",
err, lines, err, lines,
) )
} }
@@ -339,24 +340,6 @@ func (tc *testClient) readUntilClosed() []string {
return lines return lines
} }
// readServerPing reads until the PING the test server sends a
// quiet client, and fails the test if it came sooner than
// testPingInterval after lastSent, a time just before the
// client sent its last line.
func (tc *testClient) readServerPing(lastSent time.Time) {
tc.t.Helper()
tc.readUntil(isServerPing)
waited := time.Since(lastSent)
if waited < testPingInterval {
tc.t.Errorf(
"PING came %v after the client's last line, want at least %v",
waited, testPingInterval,
)
}
}
// 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(
@@ -432,63 +415,6 @@ func TestRegistration(t *testing.T) {
assertContains(t, lines, " 001 ", "RPL_WELCOME") assertContains(t, lines, " 001 ", "RPL_WELCOME")
} }
// TestRegistrationWhileAnotherLookupIsPending checks that a
// client is accepted and registered while the hostname lookup
// of an earlier client has not returned, and that it
// registers with the hostname its own lookup returned.
func TestRegistrationWhileAnotherLookupIsPending(t *testing.T) {
t.Parallel()
const lookedUpHost = "client.example"
var lookups atomic.Int32
firstLookupStarted := make(chan struct{})
// The first lookup returns only when the server stops.
resolve := func(ctx context.Context, _ string) string {
if lookups.Add(1) == 1 {
close(firstLookupStarted)
<-ctx.Done()
}
return lookedUpHost
}
env := newTestEnvWithResolver(
t, &config.Config{ServerName: testServerName},
ircserver.PingInterval, ircserver.PongDeadline, resolve,
)
env.dial(t)
select {
case <-firstLookupStarted:
case <-time.After(testTimeout):
t.Fatal("the first client's lookup did not start")
}
lines := env.dial(t).register("second")
assertContains(
t, lines, "second!second@"+lookedUpHost,
"RPL_WELCOME with the looked-up hostname",
)
}
// TestEachTestEnvHasItsOwnDatabase checks that a test
// environment starts on an empty database while another one
// is still open.
func TestEachTestEnvHasItsOwnDatabase(t *testing.T) {
t.Parallel()
first := newTestEnv(t)
first.dial(t).register("samenick")
second := newTestEnv(t)
lines := second.dial(t).register("samenick")
assertContains(t, lines, " 001 ", "RPL_WELCOME")
}
func TestWelcomeContainsNick(t *testing.T) { func TestWelcomeContainsNick(t *testing.T) {
t.Parallel() t.Parallel()
@@ -516,164 +442,6 @@ func TestPingPong(t *testing.T) {
assertContains(t, lines, "PONG", "PONG response") assertContains(t, lines, "PONG", "PONG response")
} }
// TestQuietClientAnsweringPingStaysConnected checks that a
// registered client that sends nothing is sent a PING once
// the ping interval has passed since its last line, and stays
// connected as long as it answers each one, whether with a
// PONG or with another line.
func TestQuietClientAnsweringPingStaysConnected(t *testing.T) {
t.Parallel()
tests := []struct {
name string
answer string
}{
{name: "PONG", answer: "PONG :" + testServerName},
{name: "PING of its own", answer: "PING :answer"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
env := newTestEnvWithShortPings(t)
client := env.dial(t)
lastSent := time.Now()
client.register("answers")
// The second PING comes later than a client that
// answers none stays connected.
for range 2 {
client.readServerPing(lastSent)
lastSent = time.Now()
client.send(tt.answer)
}
client.sendAndExpect("PING :still here", "still here")
})
}
}
// TestQuietClientNotAnsweringPingIsClosed checks that a
// registered client that does not answer the PING is
// disconnected once the PONG deadline has passed after it.
func TestQuietClientNotAnsweringPingIsClosed(t *testing.T) {
t.Parallel()
// latest leaves room for a loaded host, but is sooner than
// a read deadline of two ping intervals would close it.
const (
earliest = testPingInterval + testPongDeadline
latest = 2 * testPingInterval
)
env := newTestEnvWithShortPings(t)
client := env.dial(t)
lastSent := time.Now()
client.register("silent")
client.readServerPing(lastSent)
client.readUntilClosed()
waited := time.Since(lastSent)
if waited < earliest || waited >= latest {
t.Errorf(
"closed %v after the client's last line, want %v to %v",
waited, earliest, latest,
)
}
}
// TestUnregisteredClientAnswersPing checks that a PONG before
// registration, the answer to the PING unregistered clients
// are sent too, gets no ERR_NOTREGISTERED.
func TestUnregisteredClientAnswersPing(t *testing.T) {
t.Parallel()
env := newTestEnvWithShortPings(t)
client := env.dial(t)
client.readUntil(isServerPing)
client.send("PONG :" + testServerName)
lines := client.sendAndExpect("PING :after", "PONG")
assertNotContains(t, lines, " 451 ", "ERR_NOTREGISTERED")
}
// TestNewPingTimes checks that a server made by New sends a
// PING after 90 seconds with nothing read, and closes the
// connection 30 seconds after that, as README.md states.
func TestNewPingTimes(t *testing.T) {
t.Parallel()
const (
wantInterval = 90 * time.Second
wantDeadline = 30 * time.Second
)
log, err := logger.New(nil, logger.Params{
Globals: &globals.Globals{},
})
if err != nil {
t.Fatal(err)
}
// With no IRC_LISTEN_ADDR, New starts no listener and
// uses nothing else from its params.
srv := ircserver.New(fxtest.NewLifecycle(t), ircserver.Params{
Logger: log,
Config: &config.Config{},
})
interval, deadline := srv.PingTimes()
if interval != wantInterval || deadline != wantDeadline {
t.Errorf(
"PING after %v and close %v later, want %v and %v",
interval, deadline, wantInterval, wantDeadline,
)
}
}
// TestIdleCleanupKeepsActiveUser checks that a user who
// sends commands is kept by the idle cleanup, and a user who
// sends nothing is not.
func TestIdleCleanupKeepsActiveUser(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
active := env.dial(t)
active.register("active")
idle := env.dial(t)
idle.register("idle")
// Both users were last seen before cutoff, and only active
// sends a command after it, so the result does not depend
// on how long the PONG takes to arrive.
cutoff := time.Now()
active.sendAndExpect("PING :still here", "PONG")
// The idle cleanup in internal/handlers removes exactly
// the users this returns.
stale, err := env.database.GetStaleOrphanSessions(
t.Context(), cutoff,
)
if err != nil {
t.Fatalf("get stale sessions: %v", err)
}
if len(stale) != 1 || stale[0].Nick != "idle" {
t.Errorf("cleanup removes %v, want only idle", stale)
}
}
func TestJoinChannel(t *testing.T) { func TestJoinChannel(t *testing.T) {
t.Parallel() t.Parallel()
@@ -691,37 +459,6 @@ 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()
@@ -772,68 +509,6 @@ func TestDuplicateNick(t *testing.T) {
assertContains(t, lines, " 433 ", "ERR_NICKNAMEINUSE") assertContains(t, lines, " 433 ", "ERR_NICKNAMEINUSE")
} }
// TestNickRefused checks that NICK, before and after
// registration, answers a nick the HTTP API refuses with
// ERR_ERRONEUSNICKNAME and keeps the nick as it was, and
// that another member of the channel sees no line from it.
func TestNickRefused(t *testing.T) {
t.Parallel()
// Each is what follows "NICK " on the wire.
refused := []string{
":bob PRIVMSG #inj :hi", // contains spaces
":", // empty
strings.Repeat("a", 33), // one character too long
"bob!x",
"bob@x",
"#bob",
}
env := newTestEnv(t)
// Before registration: the 432 is addressed to the nick
// sent first, and USER registers with that nick.
early := env.dial(t)
early.send("NICK early")
for _, param := range refused {
lines := early.sendAndExpect("NICK "+param, " 432 ")
assertContains(t, lines, " 432 early ", "before: "+param)
}
welcome := early.sendAndExpect("USER early 0 * :Test", " 001 ")
assertContains(t, welcome, " 001 early ", "registered as early")
// After registration.
bob := env.dial(t)
bob.register("bob")
bob.joinAndDrain("#inj")
mallory := env.dial(t)
mallory.register("mallory")
mallory.joinAndDrain("#inj")
bob.readUntil(func(l string) bool {
return strings.Contains(l, "JOIN") &&
strings.Contains(l, "mallory")
})
for _, param := range refused {
lines := mallory.sendAndExpect("NICK "+param, " 432 ")
assertContains(t, lines, " 432 mallory ", "after: "+param)
}
mallory.send("PRIVMSG #inj :still mallory")
bobLines := bob.readUntil(func(l string) bool {
return strings.Contains(l, "still mallory")
})
assertNotContains(t, bobLines, " NICK ", "bob sees no NICK")
assertContains(
t, bobLines, ":mallory!", "bob sees mallory's message",
)
}
func TestListChannels(t *testing.T) { func TestListChannels(t *testing.T) {
t.Parallel() t.Parallel()
@@ -875,120 +550,6 @@ func TestQuit(t *testing.T) {
assertContains(t, lines, "goodbye", "QUIT reason") assertContains(t, lines, "goodbye", "QUIT reason")
} }
// TestStopEndsSessions checks that stopping the server ends
// a registered connection's session before Stop returns, as
// closing the connection does: the session, its client and
// its channel membership are gone from the database, and a
// channel peer has been sent the QUIT.
func TestStopEndsSessions(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("shutirc")
client.joinAndDrain("#shut")
ctx := t.Context()
// A peer on the HTTP API, whose session outlives the stop.
peerID, peerClientID, _, err := env.database.CreateSession(
ctx, "peer", "peer", "", "",
)
if err != nil {
t.Fatalf("create peer session: %v", err)
}
channelID, err := env.database.GetChannelByName(ctx, "#shut")
if err != nil {
t.Fatalf("get channel: %v", err)
}
err = env.database.JoinChannel(ctx, channelID, peerID)
if err != nil {
t.Fatalf("join peer: %v", err)
}
env.srv.Stop()
_, err = env.database.GetSessionByNick(ctx, "shutirc")
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("look up shutirc after stop: %v, want no rows", err)
}
clients, err := env.database.GetClientCount(ctx)
if err != nil {
t.Fatalf("count clients: %v", err)
}
if clients != 1 {
t.Errorf("%d clients after stop, want only the peer's", clients)
}
members, err := env.database.GetChannelMemberIDs(ctx, channelID)
if err != nil {
t.Fatalf("get members: %v", err)
}
if len(members) != 1 || members[0] != peerID {
t.Errorf(
"members of #shut after stop: %v, want only the peer %d",
members, peerID,
)
}
msgs, _, err := env.database.PollMessages(ctx, peerClientID, 0, 0)
if err != nil {
t.Fatalf("poll peer: %v", err)
}
for _, msg := range msgs {
if msg.Command == "QUIT" && msg.From == "shutirc" {
return
}
}
t.Errorf("peer was not sent the QUIT of shutirc: %v", msgs)
}
// TestStopReturnsWhileCleanupIsStuck checks that Stop stops
// waiting for a connection's cleanup after StopTimeout, so a
// cleanup that cannot finish does not hold up stopping.
func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("stuck")
// The test database has one connection. While the test
// holds it, the cleanup's first query waits for it.
held, err := env.database.GetDB().Conn(t.Context())
if err != nil {
t.Fatalf("take the database connection: %v", err)
}
stopped := make(chan struct{})
go func() {
env.srv.Stop()
close(stopped)
}()
select {
case <-stopped:
case <-time.After(ircserver.StopTimeout + testTimeout):
t.Error("Stop did not return while a cleanup was stuck")
}
// Lets the cleanup finish before the database is closed.
err = held.Close()
if err != nil {
t.Errorf("release the database connection: %v", err)
}
}
func TestTopicSetAndGet(t *testing.T) { func TestTopicSetAndGet(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1160,165 +721,6 @@ func TestHandlePassPostRegistration(t *testing.T) {
) )
} }
// The shortest and longest password PASS accepts; bcrypt
// cannot hash more than 72 bytes.
const (
minPasswordBytes = 8
maxPasswordBytes = 72
)
// passwordLengthNotice is the NOTICE text for a password that
// is too short or too long.
const passwordLengthNotice = "Password must be 8 to 72 bytes"
// passwordCase is a password a PASS test sends, and whether
// the server should store it.
type passwordCase struct {
name string
password string
stored bool
}
// passwordCases returns the passwords the PASS length tests
// send: one of 8 to 72 bytes is stored and any other is
// refused. The limit counts bytes: "é" is two bytes in UTF-8,
// so 72 of them are refused.
func passwordCases() []passwordCase {
return []passwordCase{
{"empty", "", false},
{"7 bytes", strings.Repeat("a", minPasswordBytes-1), false},
{"8 bytes", strings.Repeat("a", minPasswordBytes), true},
{"72 bytes", strings.Repeat("a", maxPasswordBytes), true},
{"73 bytes", strings.Repeat("a", maxPasswordBytes+1), false},
{"72 characters", strings.Repeat("é", maxPasswordBytes), false},
}
}
// assertStored fails the test unless nick can log in with
// password exactly when stored is true.
func assertStored(
t *testing.T, env *testEnv, nick, password string, stored bool,
) {
t.Helper()
_, _, token, err := env.database.LoginUser(
t.Context(), nick, password, "127.0.0.1", "localhost",
)
loggedIn := err == nil && token != ""
if loggedIn != stored {
t.Errorf(
"log in as %s: logged in %v, want %v (error: %v)",
nick, loggedIn, stored, err,
)
}
}
// TestPassPostRegistrationLength checks that PASS after
// registration stores and confirms a password of 8 to 72
// bytes, and refuses any other with a NOTICE.
func TestPassPostRegistrationLength(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
for i, tc := range passwordCases() {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
nick := fmt.Sprintf("postpass%d", i)
client := env.dial(t)
client.register(nick)
client.send("PASS :" + tc.password)
// The server answers commands in order, so its
// reply to PASS comes before the PONG.
lines := client.sendAndExpect("PING :after", "PONG")
reply := passwordLengthNotice
if tc.stored {
reply = "Password set"
}
assertContains(t, lines, reply, "reply to PASS")
assertStored(t, env, nick, tc.password, tc.stored)
})
}
}
// TestPassPostRegistrationNotStored checks that PASS is not
// confirmed when the password could not be stored.
func TestPassPostRegistrationNotStored(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("failpass")
_, err := env.database.GetDB().ExecContext(t.Context(),
`CREATE TRIGGER refuse_password
BEFORE UPDATE OF password_hash ON sessions
BEGIN SELECT RAISE(ABORT, 'refused'); END`,
)
if err != nil {
t.Fatalf("create trigger: %v", err)
}
client.send("PASS :mypassword123")
// The server answers commands in order, so anything it
// sent about PASS comes before the PONG.
lines := client.sendAndExpect("PING :after", "PONG")
assertNotContains(
t, lines, "Password set",
"confirmation of a password that was not stored",
)
}
// TestPassBeforeRegistrationLength checks that a PASS before
// registration stores a password of 8 to 72 bytes, and that
// any other is refused with a NOTICE once registration
// completes.
func TestPassBeforeRegistrationLength(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
for i, tc := range passwordCases() {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
nick := fmt.Sprintf("prepass%d", i)
client := env.dial(t)
client.send("PASS :" + tc.password)
client.register(nick)
// The password is checked after the welcome
// burst, which register reads; the PONG comes
// after that.
lines := client.sendAndExpect("PING :after", "PONG")
if tc.stored {
assertNotContains(
t, lines, passwordLengthNotice,
"refusal of a valid password",
)
} else {
assertContains(
t, lines, passwordLengthNotice,
"refusal after registration",
)
}
assertStored(t, env, nick, tc.password, tc.stored)
})
}
}
func TestPreRegistrationNotRegistered(t *testing.T) { func TestPreRegistrationNotRegistered(t *testing.T) {
t.Parallel() t.Parallel()
-22
View File
@@ -1,22 +0,0 @@
package server
import (
"net"
"github.com/go-chi/chi/v5"
)
// NewTestSPAServer creates a Server whose router serves only
// the embedded web client.
func NewTestSPAServer() *Server {
srv := &Server{router: chi.NewRouter()}
srv.setupSPA()
return srv
}
// ServeUntilShutdown runs serveUntilShutdown, which serves the
// HTTP server on listener, and returns when it does.
func (srv *Server) ServeUntilShutdown(listener net.Listener) {
srv.serveUntilShutdown(listener)
}
+1 -4
View File
@@ -3,7 +3,6 @@ package server
import ( import (
"io/fs" "io/fs"
"net/http" "net/http"
"strings"
"time" "time"
"sneak.berlin/go/neoirc/web" "sneak.berlin/go/neoirc/web"
@@ -138,10 +137,8 @@ func (srv *Server) setupSPA() {
return return
} }
// The path is empty, not "/", for a request line such
// as "GET http://host HTTP/1.1".
fileData, readErr := readFS.ReadFile( fileData, readErr := readFS.ReadFile(
strings.TrimPrefix(request.URL.Path, "/"), request.URL.Path[1:],
) )
if readErr != nil || len(fileData) == 0 { if readErr != nil || len(fileData) == 0 {
indexHTML, indexErr := readFS.ReadFile( indexHTML, indexErr := readFS.ReadFile(
-41
View File
@@ -1,41 +0,0 @@
package server_test
import (
"io/fs"
"net/http"
"net/http/httptest"
"testing"
"sneak.berlin/go/neoirc/internal/server"
"sneak.berlin/go/neoirc/web"
)
// An absolute-form request line with no path, such as
// "GET http://example.com HTTP/1.1", leaves URL.Path empty.
func TestSPAServesIndexForEmptyPath(t *testing.T) {
t.Parallel()
request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "http://example.com", nil,
)
if request.URL.Path != "" {
t.Fatalf("URL.Path is %q, want it empty", request.URL.Path)
}
recorder := httptest.NewRecorder()
server.NewTestSPAServer().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status %d, want %d", recorder.Code, http.StatusOK)
}
indexHTML, err := fs.ReadFile(web.Dist, "dist/index.html")
if err != nil {
t.Fatal(err)
}
if recorder.Body.String() != string(indexHTML) {
t.Errorf("body is not the index page")
}
}
+50 -47
View File
@@ -6,7 +6,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -40,15 +39,17 @@ type Params struct {
Config *config.Config Config *config.Config
Middleware *middleware.Middleware Middleware *middleware.Middleware
Handlers *handlers.Handlers Handlers *handlers.Handlers
Shutdowner fx.Shutdowner
} }
// Server is the main HTTP server. // Server is the main HTTP server.
// It manages routing, middleware, and lifecycle. // It manages routing, middleware, and lifecycle.
type Server struct { type Server struct {
startupTime time.Time startupTime time.Time
exitCode int
sentryEnabled bool sentryEnabled bool
log *slog.Logger log *slog.Logger
ctx context.Context //nolint:containedctx // signal handling pattern
cancelFunc context.CancelFunc
httpServer *http.Server httpServer *http.Server
router *chi.Mux router *chi.Mux
params Params params Params
@@ -68,7 +69,7 @@ func New(
} }
lifecycle.Append(fx.Hook{ lifecycle.Append(fx.Hook{
OnStart: func(ctx context.Context) error { OnStart: func(_ context.Context) error {
srv.startupTime = time.Now() srv.startupTime = time.Now()
// Build the router before OnStart returns, so srv can // Build the router before OnStart returns, so srv can
@@ -77,14 +78,13 @@ func New(
srv.enableSentry() srv.enableSentry()
srv.SetupRoutes() srv.SetupRoutes()
// A closed stdout or stderr must not end the process. // The start hook's context ends when the hook
signal.Ignore(syscall.SIGPIPE) // returns; serving must outlive it.
go srv.serve() //nolint:contextcheck,gosec // G118
return srv.startHTTP(ctx) return nil
}, },
OnStop: func(ctx context.Context) error { OnStop: func(_ context.Context) error {
srv.cleanShutdown(ctx)
return nil return nil
}, },
}) })
@@ -129,15 +129,44 @@ func (srv *Server) enableSentry() {
srv.sentryEnabled = true srv.sentryEnabled = true
} }
func (srv *Server) serve() int {
srv.ctx, srv.cancelFunc = context.WithCancel(
context.Background(),
)
go func() {
sigCh := make(chan os.Signal, 1)
signal.Ignore(syscall.SIGPIPE)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
sig := <-sigCh
srv.log.Info("signal received", "signal", sig)
if srv.cancelFunc != nil {
srv.cancelFunc()
}
}()
go srv.serveUntilShutdown()
<-srv.ctx.Done()
srv.cleanShutdown()
return srv.exitCode
}
func (srv *Server) cleanupForExit() { func (srv *Server) cleanupForExit() {
srv.log.Info("cleaning up") srv.log.Info("cleaning up")
} }
// cleanShutdown stops the HTTP server, waiting up to func (srv *Server) cleanShutdown() {
// shutdownTimeout for the requests in progress to finish. srv.exitCode = 0
func (srv *Server) cleanShutdown(ctx context.Context) {
ctxShutdown, shutdownCancel := context.WithTimeout( ctxShutdown, shutdownCancel := context.WithTimeout(
ctx, shutdownTimeout, context.Background(), shutdownTimeout,
) )
err := srv.httpServer.Shutdown(ctxShutdown) err := srv.httpServer.Shutdown(ctxShutdown)
@@ -162,23 +191,11 @@ func (srv *Server) configure() {
// Server configuration placeholder. // Server configuration placeholder.
} }
// startHTTP binds the HTTP listen address, so that a port func (srv *Server) serveUntilShutdown() {
// that is taken fails startup, then serves the HTTP server
// from a new goroutine. srv.httpServer is set before that
// goroutine starts and never changes, so it and cleanShutdown
// read it without a lock.
func (srv *Server) startHTTP(ctx context.Context) error {
listenAddr := fmt.Sprintf( listenAddr := fmt.Sprintf(
":%d", srv.params.Config.Port, ":%d", srv.params.Config.Port,
) )
var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(ctx, "tcp", listenAddr)
if err != nil {
return fmt.Errorf("http listen: %w", err)
}
srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields
Addr: listenAddr, Addr: listenAddr,
ReadTimeout: httpReadTimeout, ReadTimeout: httpReadTimeout,
@@ -187,30 +204,16 @@ func (srv *Server) startHTTP(ctx context.Context) error {
Handler: srv, Handler: srv,
} }
go func() {
srv.serveUntilShutdown(listener)
}()
return nil
}
// serveUntilShutdown serves the HTTP server on listener until
// cleanShutdown stops it. If serving fails before then, it
// stops the app with exit code 1.
func (srv *Server) serveUntilShutdown(listener net.Listener) {
srv.log.Info( srv.log.Info(
"http begin listen", "listenaddr", srv.httpServer.Addr, "http begin listen", "listenaddr", listenAddr,
) )
err := srv.httpServer.Serve(listener) err := srv.httpServer.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) { if err != nil && !errors.Is(err, http.ErrServerClosed) {
return srv.log.Error("listen error", "error", err)
}
srv.log.Error("listen error", "error", err) if srv.cancelFunc != nil {
srv.cancelFunc()
err = srv.params.Shutdowner.Shutdown(fx.ExitCode(1)) }
if err != nil {
srv.log.Error("shutdown failed", "error", err)
} }
} }
-358
View File
@@ -1,358 +0,0 @@
// Tests use a global viper instance for configuration,
// making parallel execution unsafe.
//
//nolint:paralleltest
package server_test
import (
"crypto/rand"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"testing"
"time"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
"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/handlers"
"sneak.berlin/go/neoirc/internal/healthcheck"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/middleware"
"sneak.berlin/go/neoirc/internal/server"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/internal/stats"
)
const (
testTimeout = 5 * time.Second
// checkStep is how often waitUntil checks again.
checkStep = 10 * time.Millisecond
// stopHoldTime is how long a test waits to see that the
// app has not stopped.
stopHoldTime = 200 * time.Millisecond
)
// TestStopWaitsForRequestInProgress checks that stopping the
// app, which runs the stop hook server.New registers, waits
// for a long poll the HTTP server is serving to finish, and
// that the poll gets its response.
func TestStopWaitsForRequestInProgress(t *testing.T) {
port := freePort(t)
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
app, brk, database := startApp(t, port)
sessionID, _, token, err := database.CreateSession(
t.Context(), "poller", "poller", "", "",
)
if err != nil {
t.Fatalf("create session: %v", err)
}
waitUntil(t, "the HTTP server to listen", func() bool {
return canConnect(t, addr)
})
statuses := make(chan int, 1)
go func() {
statuses <- getAuthenticated(
t, "http://"+addr+"/api/v1/messages?timeout=30", token,
)
}()
waitUntil(t, "the long poll to wait for a message", func() bool {
return brk.ListenerCount(sessionID) == 1
})
var stopErr error
stopped := make(chan struct{})
go func() {
stopErr = app.Stop(t.Context())
close(stopped)
}()
select {
case <-stopped:
t.Error("the app stopped while a request was in progress")
case <-time.After(stopHoldTime):
}
brk.Notify(sessionID)
status := <-statuses
if status != http.StatusOK {
t.Errorf("status %d, want %d", status, http.StatusOK)
}
select {
case <-stopped:
if stopErr != nil {
t.Errorf("stop: %v", stopErr)
}
case <-time.After(testTimeout):
t.Fatal("the app did not stop after the request finished")
}
}
// TestStartFailsWhenHTTPPortIsTaken checks that starting the
// app, which runs the start hook server.New registers,
// returns an error naming the HTTP listen address when
// something else is already listening on its port.
func TestStartFailsWhenHTTPPortIsTaken(t *testing.T) {
var listenConfig net.ListenConfig
taken, err := listenConfig.Listen(t.Context(), "tcp", ":0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = taken.Close() })
tcpAddr, ok := taken.Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("listener address %v is not TCP", taken.Addr())
}
app, _, _ := newApp(t, tcpAddr.Port)
err = app.Start(t.Context())
if err == nil {
app.RequireStop()
t.Fatal("the app started while its HTTP port was taken")
}
listenAddr := fmt.Sprintf(":%d", tcpAddr.Port)
if !strings.Contains(err.Error(), listenAddr) {
t.Errorf("start error %q does not name %s", err, listenAddr)
}
}
// TestServeFailureStopsApp checks that when serving HTTP fails
// after the start hook has bound the listen address, the
// server stops the app with exit code 1.
func TestServeFailureStopsApp(t *testing.T) {
var srv *server.Server
app, _, _ := startApp(t, freePort(t), fx.Populate(&srv))
shutdowns := app.Wait()
srv.ServeUntilShutdown(closedListener(t))
select {
case shutdown := <-shutdowns:
if shutdown.ExitCode != 1 {
t.Errorf("exit code %d, want 1", shutdown.ExitCode)
}
default:
t.Error("the server did not stop the app")
}
}
// TestServeAfterStopDoesNotStopApp checks that serving HTTP
// after the app has stopped, which ends at once because the
// stop hook has shut the HTTP server down, does not stop the
// app. Serving ends the same way on every normal stop.
func TestServeAfterStopDoesNotStopApp(t *testing.T) {
var srv *server.Server
app, _, _ := startApp(t, freePort(t), fx.Populate(&srv))
shutdowns := app.Wait()
app.RequireStop()
srv.ServeUntilShutdown(closedListener(t))
select {
case shutdown := <-shutdowns:
t.Errorf(
"the server stopped the app with exit code %d",
shutdown.ExitCode,
)
default:
}
}
// startApp starts the app newApp builds, with the HTTP server
// listening on port.
func startApp(
t *testing.T,
port int,
options ...fx.Option,
) (*fxtest.App, *broker.Broker, *db.Database) {
t.Helper()
app, brk, database := newApp(t, port, options...)
app.RequireStart()
// Stops the app if the test ends before stopping it.
t.Cleanup(app.RequireStop)
return app, brk, database
}
// newApp builds, without starting it, an app that runs the
// server with the dependencies cmd/neoircd gives it, on a
// fresh in-memory database, with the HTTP port set to port.
// options are added to the app, for a test that needs more
// from it than the broker and the database.
func newApp(
t *testing.T,
port int,
options ...fx.Option,
) (*fxtest.App, *broker.Broker, *db.Database) {
t.Helper()
var (
brk *broker.Broker
database *db.Database
)
app := fxtest.New(t,
fx.Supply(&globals.Globals{
Appname: "neoirc-test",
Version: "test",
StartTime: time.Now(),
}),
fx.Provide(
broker.New,
config.New,
db.New,
handlers.New,
healthcheck.New,
logger.New,
middleware.New,
server.New,
service.New,
stats.New,
),
fx.Decorate(func(cfg *config.Config) *config.Config {
// A random name, so that no other test can share it.
cfg.DBURL = fmt.Sprintf(
"file:%s?mode=memory&cache=shared", rand.Text(),
)
cfg.Port = port
return cfg
}),
fx.Invoke(func(*server.Server) {}),
fx.Populate(&brk, &database),
fx.Options(options...),
)
return app, brk, database
}
// freePort returns a TCP port that nothing was listening on a
// moment ago.
func freePort(t *testing.T) int {
t.Helper()
listener := closedListener(t)
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("listener address %v is not TCP", listener.Addr())
}
return tcpAddr.Port
}
// closedListener returns a TCP listener that has already been
// closed, so that accepting a connection on it fails.
func closedListener(t *testing.T) net.Listener {
t.Helper()
var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(
t.Context(), "tcp", "127.0.0.1:0",
)
if err != nil {
t.Fatalf("listen: %v", err)
}
err = listener.Close()
if err != nil {
t.Fatalf("close listener: %v", err)
}
return listener
}
// canConnect reports whether addr accepts a TCP connection.
func canConnect(t *testing.T, addr string) bool {
t.Helper()
var dialer net.Dialer
conn, err := dialer.DialContext(t.Context(), "tcp", addr)
if err != nil {
return false
}
_ = conn.Close()
return true
}
// waitUntil calls done every checkStep until it returns true,
// and fails the test if it does not within testTimeout.
func waitUntil(t *testing.T, what string, done func() bool) {
t.Helper()
deadline := time.Now().Add(testTimeout)
for !done() {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %s", what)
}
time.Sleep(checkStep)
}
}
// getAuthenticated sends a GET request to url as the session
// whose auth token is token, and returns the response status,
// or 0 if there was no response.
func getAuthenticated(t *testing.T, url, token string) int {
t.Helper()
request, err := http.NewRequestWithContext(
t.Context(), http.MethodGet, url, nil,
)
if err != nil {
t.Errorf("new request: %v", err)
return 0
}
request.Header.Set("Cookie", "neoirc_auth="+token)
client := http.Client{Timeout: testTimeout}
response, err := client.Do(request)
if err != nil {
t.Errorf("get %s: %v", url, err)
return 0
}
_ = response.Body.Close()
return response.StatusCode
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+63 -114
View File
@@ -58,13 +58,13 @@ async function mintHashcash(bits, resource) {
} }
} }
// Requests are authenticated by the auth cookie, which the browser
// sends on its own.
function api(path, opts = {}) { function api(path, opts = {}) {
const token = localStorage.getItem("neoirc_token");
const headers = { const headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
...(opts.headers || {}), ...(opts.headers || {}),
}; };
if (token) headers["Authorization"] = `Bearer ${token}`;
const { signal, ...rest } = opts; const { signal, ...rest } = opts;
return fetch(API + path, { ...rest, headers, signal }).then(async (r) => { return fetch(API + path, { ...rest, headers, signal }).then(async (r) => {
@@ -122,11 +122,12 @@ function LoginScreen({ onLogin }) {
if (s.name) hashcashResourceRef.current = s.name; if (s.name) hashcashResourceRef.current = s.name;
}) })
.catch(() => {}); .catch(() => {});
// Resume the session if the browser still holds a valid auth const saved = localStorage.getItem("neoirc_token");
// cookie; a 401 leaves the login form up. if (saved) {
api("/state?initChannelState=1") api("/state?initChannelState=1")
.then((u) => onLogin(u.nick, true)) .then((u) => onLogin(u.nick, true))
.catch(() => {}); .catch(() => localStorage.removeItem("neoirc_token"));
}
inputRef.current?.focus(); inputRef.current?.focus();
}, []); }, []);
@@ -151,6 +152,7 @@ function LoginScreen({ onLogin }) {
method: "POST", method: "POST",
body: JSON.stringify(reqBody), body: JSON.stringify(reqBody),
}); });
localStorage.setItem("neoirc_token", res.token);
onLogin(res.nick); onLogin(res.nick);
} catch (err) { } catch (err) {
setError(err.data?.error || "Connection failed"); setError(err.data?.error || "Connection failed");
@@ -225,9 +227,10 @@ function UserList({ members, onNickClick }) {
const regular = []; const regular = [];
for (const m of members) { for (const m of members) {
if (m.isOperator) { const mode = m.mode || "";
if (mode === "o") {
ops.push(m); ops.push(m);
} else if (m.isVoiced) { } else if (mode === "v") {
voiced.push(m); voiced.push(m);
} else { } else {
regular.push(m); regular.push(m);
@@ -300,14 +303,11 @@ function App() {
nickRef.current = nick; nickRef.current = nick;
}, [nick]); }, [nick]);
// Persist joined channels, once logged in: before then only the // Persist joined channels.
// Server tab exists, and writing would erase the saved channels
// that onLogin rejoins.
useEffect(() => { useEffect(() => {
if (!loggedIn) return;
const channels = tabs.filter((t) => t.type === "channel").map((t) => t.name); const channels = tabs.filter((t) => t.type === "channel").map((t) => t.name);
localStorage.setItem("neoirc_channels", JSON.stringify(channels)); localStorage.setItem("neoirc_channels", JSON.stringify(channels));
}, [loggedIn, tabs]); }, [tabs]);
// Clear unread on tab switch. // Clear unread on tab switch.
useEffect(() => { useEffect(() => {
@@ -366,9 +366,7 @@ function App() {
command: msg.command, command: msg.command,
}; };
// A numeric reply carries its number in `code` and its name in switch (msg.command) {
// `command`; other messages have no `code`.
switch (msg.code || msg.command) {
case "PRIVMSG": case "PRIVMSG":
case "NOTICE": { case "NOTICE": {
let text = body; let text = body;
@@ -466,18 +464,8 @@ function App() {
break; break;
} }
case "MODE": { case "353": {
// The body is the change, with its value where it has one: // RPL_NAMREPLY — parse mode prefixes from names list.
// ["+o bob"], ["+m"], ["+k", "secretpass"].
const modes = Array.isArray(msg.body) ? msg.body.join(" ") : "";
const text = `${msg.from} sets mode ${modes} on ${msg.to}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
break;
}
case 353: {
// RPL_NAMREPLY — each name is a hostmask (nick!user@host),
// prefixed with @ for an operator or + for a voiced member.
if ( if (
Array.isArray(msg.params) && Array.isArray(msg.params) &&
msg.params.length >= 2 && msg.params.length >= 2 &&
@@ -489,18 +477,21 @@ function App() {
: String(msg.body); : String(msg.body);
const names = namesStr.split(/\s+/).filter(Boolean); const names = namesStr.split(/\s+/).filter(Boolean);
const parsed = names.map((n) => { const parsed = names.map((n) => {
const isOperator = n.startsWith("@"); if (n.startsWith("@")) {
const isVoiced = n.startsWith("+"); return { nick: n.slice(1), mode: "o" };
const hostmask = isOperator || isVoiced ? n.slice(1) : n; }
if (n.startsWith("+")) {
return { nick: n.slice(1), mode: "v" };
}
return { nick: hostmask.split("!")[0], isOperator, isVoiced }; return { nick: n, mode: "" };
}); });
setMembers((prev) => ({ ...prev, [channel]: parsed })); setMembers((prev) => ({ ...prev, [channel]: parsed }));
} }
break; break;
} }
case 332: { case "332": {
// RPL_TOPIC — set topic from server. // RPL_TOPIC — set topic from server.
if (Array.isArray(msg.params) && msg.params.length >= 1) { if (Array.isArray(msg.params) && msg.params.length >= 1) {
const channel = msg.params[0]; const channel = msg.params[0];
@@ -512,7 +503,7 @@ function App() {
break; break;
} }
case 322: { case "322": {
// RPL_LIST — channel, member count, topic. // RPL_LIST — channel, member count, topic.
if (Array.isArray(msg.params) && msg.params.length >= 2) { if (Array.isArray(msg.params) && msg.params.length >= 2) {
const chName = msg.params[0]; const chName = msg.params[0];
@@ -527,7 +518,7 @@ function App() {
break; break;
} }
case 323: case "323":
addMessage("Server", { addMessage("Server", {
...base, ...base,
text: body || "End of channel list", text: body || "End of channel list",
@@ -535,7 +526,7 @@ function App() {
}); });
break; break;
case 352: { case "352": {
// RPL_WHOREPLY — channel, user, host, server, nick, flags. // RPL_WHOREPLY — channel, user, host, server, nick, flags.
if (Array.isArray(msg.params) && msg.params.length >= 5) { if (Array.isArray(msg.params) && msg.params.length >= 5) {
const whoCh = msg.params[0]; const whoCh = msg.params[0];
@@ -550,7 +541,7 @@ function App() {
break; break;
} }
case 315: case "315":
addMessage("Server", { addMessage("Server", {
...base, ...base,
text: body || "End of /WHO list", text: body || "End of /WHO list",
@@ -558,20 +549,20 @@ function App() {
}); });
break; break;
case 311: { case "311": {
// RPL_WHOISUSER — params nick, user, host, *; body realname. // RPL_WHOISUSER — nick, user, host, *, realname.
if (Array.isArray(msg.params) && msg.params.length >= 3) { if (Array.isArray(msg.params) && msg.params.length >= 1) {
const [wiNick, wiUser, wiHost] = msg.params; const wiNick = msg.params[0];
addMessage("Server", { addMessage("Server", {
...base, ...base,
text: `${wiNick} is ${wiUser}@${wiHost} (${body})`, text: `${wiNick} (${body})`,
system: true, system: true,
}); });
} }
break; break;
} }
case 312: { case "312": {
// RPL_WHOISSERVER — nick, server, server info. // RPL_WHOISSERVER — nick, server, server info.
if (Array.isArray(msg.params) && msg.params.length >= 2) { if (Array.isArray(msg.params) && msg.params.length >= 2) {
const wiNick = msg.params[0]; const wiNick = msg.params[0];
@@ -585,33 +576,7 @@ function App() {
break; break;
} }
case 313: { case "319": {
// RPL_WHOISOPERATOR — nick; body "is an IRC operator".
if (Array.isArray(msg.params) && msg.params.length >= 1) {
addMessage("Server", {
...base,
text: `${msg.params[0]} ${body}`,
system: true,
});
}
break;
}
case 317: {
// RPL_WHOISIDLE — nick, idle seconds, signon time (Unix).
if (Array.isArray(msg.params) && msg.params.length >= 3) {
const [wiNick, idle, signon] = msg.params;
const signedOn = new Date(Number(signon) * 1000).toLocaleString();
addMessage("Server", {
...base,
text: `${wiNick} has been idle ${idle}s, signed on ${signedOn}`,
system: true,
});
}
break;
}
case 319: {
// RPL_WHOISCHANNELS — nick, channels. // RPL_WHOISCHANNELS — nick, channels.
if (Array.isArray(msg.params) && msg.params.length >= 1) { if (Array.isArray(msg.params) && msg.params.length >= 1) {
const wiNick = msg.params[0]; const wiNick = msg.params[0];
@@ -624,7 +589,7 @@ function App() {
break; break;
} }
case 318: case "318":
addMessage("Server", { addMessage("Server", {
...base, ...base,
text: body || "End of /WHOIS list", text: body || "End of /WHOIS list",
@@ -632,21 +597,9 @@ function App() {
}); });
break; break;
case 324: { case "375":
// RPL_CHANNELMODEIS — channel, mode string. case "372":
if (Array.isArray(msg.params) && msg.params.length >= 2) { case "376":
addMessage("Server", {
...base,
text: `${msg.params[0]} modes: ${msg.params[1]}`,
system: true,
});
}
break;
}
case 375:
case 372:
case 376:
addMessage("Server", { ...base, text: body, system: true }); addMessage("Server", { ...base, text: body, system: true });
break; break;
@@ -684,14 +637,6 @@ function App() {
} catch (err) { } catch (err) {
if (!alive) break; if (!alive) break;
if (err.name === "AbortError") continue; if (err.name === "AbortError") continue;
// 401: the session is gone (QUIT from another client, or
// expiry). Reloading drops this page's state and brings
// back the login screen.
if (err.status === 401) {
window.location.reload();
return;
}
setConnected(false); setConnected(false);
await new Promise((r) => setTimeout(r, RECONNECT_DELAY)); await new Promise((r) => setTimeout(r, RECONNECT_DELAY));
} }
@@ -771,11 +716,6 @@ function App() {
const onLogin = useCallback( const onLogin = useCallback(
async (userNick, isResumed) => { async (userNick, isResumed) => {
// Read before setLoggedIn(true): from then on the effect under
// "Persist joined channels" writes the open channel tabs over it.
const saved = JSON.parse(
localStorage.getItem("neoirc_channels") || "[]",
);
setNick(userNick); setNick(userNick);
setLoggedIn(true); setLoggedIn(true);
addSystemMessage("Server", `Connected as ${userNick}`); addSystemMessage("Server", `Connected as ${userNick}`);
@@ -800,6 +740,9 @@ function App() {
} }
// Fresh session — join any previously saved channels. // Fresh session — join any previously saved channels.
const saved = JSON.parse(
localStorage.getItem("neoirc_channels") || "[]",
);
for (const ch of saved) { for (const ch of saved) {
try { try {
await api("/messages", { await api("/messages", {
@@ -1029,22 +972,27 @@ function App() {
break; break;
} }
// With no arguments MODE is a query. A change is one sign and
// letter, then its value where it takes one: +m, +o nick.
const modeArgs = parts.slice(1); const modeArgs = parts.slice(1);
const body = if (modeArgs.length > 0) {
modeArgs.length > 0 try {
? { command: "MODE", to: tab.name, body: modeArgs } await api("/messages", {
: { command: "MODE", to: tab.name }; method: "POST",
try { body: JSON.stringify({
await api("/messages", { command: "MODE",
method: "POST", to: tab.name,
body: JSON.stringify(body), params: modeArgs,
}); }),
} catch (err) { });
} catch (err) {
addSystemMessage(
"Server",
`Mode change failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage( addSystemMessage(
"Server", "Server",
`MODE failed: ${err.data?.error || "error"}`, "Usage: /mode <+/-mode> [params]",
); );
} }
@@ -1061,6 +1009,7 @@ function App() {
} catch (e) { } catch (e) {
// Ignore. // Ignore.
} }
localStorage.removeItem("neoirc_token");
localStorage.removeItem("neoirc_channels"); localStorage.removeItem("neoirc_channels");
window.location.reload(); window.location.reload();
@@ -1179,7 +1128,7 @@ function App() {
" /me action — Send an action", " /me action — Send an action",
" /nick newnick — Change your nickname", " /nick newnick — Change your nickname",
" /topic [text] — View or set channel topic", " /topic [text] — View or set channel topic",
" /mode [+/-flag [arg]] — View or set channel modes", " /mode +/-flags — Set channel modes",
" /motd — Display the message of the day", " /motd — Display the message of the day",
" /list — List all channels", " /list — List all channels",
" /who [#channel] — List users in a channel", " /who [#channel] — List users in a channel",