From 915f56ee02d88f34f520b9f0b6f86644d9c0ddee Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 02:49:53 +0200 Subject: [PATCH 01/40] Fix the data races and the test timeout that fail make test (closes #104) The HTTP server built its router inside the goroutine that starts serving, so the start hook returned before the router existed, and the internal/handlers tests raced with it or hit a nil router. The start hook now runs configure, enableSentry and SetupRoutes, in that order, then serves in the background. The goroutine that sends queued messages to an IRC client read c.nick without c.mu while NICK changed it. Every such read now takes the lock. Under -race in the Docker build, internal/handlers takes over 30s on database work, not clock waits, so both go test runs in make test use -timeout 120s. The || retry stays (https://git.eeqj.de/sneak/neoirc/issues/101). Model: opus-5-5 --- Makefile | 2 +- internal/ircserver/relay.go | 48 ++++++++++++++++++++++++++++++------- internal/server/server.go | 18 +++++++------- 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/Makefile b/Makefile index 67ceb3e..f00e4b7 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ fmt-check: @test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1) test: ensure-web-dist - go test -timeout 30s -race -cover ./... || go test -timeout 30s -race -v ./... + go test -timeout 120s -race -cover ./... || go test -timeout 120s -race -v ./... # check runs all validation without making changes # Used by CI and Docker build — fails if anything is wrong diff --git a/internal/ircserver/relay.go b/internal/ircserver/relay.go index cb7d185..6d4195f 100644 --- a/internal/ircserver/relay.go +++ b/internal/ircserver/relay.go @@ -128,7 +128,11 @@ func (c *Conn) deliverIRCMessage( default: // Unknown command — deliver as server notice. if text != "" { - c.sendFromServer("NOTICE", c.nick, text) + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + + c.sendFromServer("NOTICE", nick, text) } } } @@ -158,8 +162,12 @@ func (c *Conn) deliverNumeric( _ = json.Unmarshal(msg.Params, ¶ms) } + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + allParams := make([]string, 0, 1+len(params)+1) - allParams = append(allParams, c.nick) + allParams = append(allParams, nick) allParams = append(allParams, params...) if text != "" { @@ -177,8 +185,12 @@ func (c *Conn) deliverTextMessage( from := msg.From target := msg.To + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + // Don't echo our own messages back. - if strings.EqualFold(from, c.nick) { + if strings.EqualFold(from, nick) { return } @@ -192,9 +204,13 @@ func (c *Conn) deliverTextMessage( // deliverJoin sends a JOIN notification. 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, c.nick) { + if strings.EqualFold(msg.From, nick) { return } @@ -206,7 +222,11 @@ func (c *Conn) deliverJoin(msg *db.IRCMessage) { // deliverPart sends a PART notification. func (c *Conn) deliverPart(msg *db.IRCMessage, text string) { - if strings.EqualFold(msg.From, c.nick) { + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + + if strings.EqualFold(msg.From, nick) { return } @@ -227,7 +247,11 @@ func (c *Conn) deliverNickChange( msg *db.IRCMessage, newNick string, ) { - if strings.EqualFold(msg.From, c.nick) { + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + + if strings.EqualFold(msg.From, nick) { return } @@ -241,7 +265,11 @@ func (c *Conn) deliverQuitMsg( msg *db.IRCMessage, text string, ) { - if strings.EqualFold(msg.From, c.nick) { + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + + if strings.EqualFold(msg.From, nick) { return } @@ -302,7 +330,11 @@ func (c *Conn) deliverInviteMsg( _ *db.IRCMessage, text string, ) { - c.sendFromServer("NOTICE", c.nick, text) + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + + c.sendFromServer("NOTICE", nick, text) } // deliverMode sends a MODE change notification. diff --git a/internal/server/server.go b/internal/server/server.go index 50216fa..1006342 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -71,7 +71,14 @@ func New( lifecycle.Append(fx.Hook{ OnStart: func(_ context.Context) error { srv.startupTime = time.Now() - go srv.Run() //nolint:contextcheck + + // Build the router before OnStart returns, so srv can + // handle requests as soon as the app has started. + srv.configure() + srv.enableSentry() + srv.SetupRoutes() + + go srv.serve() //nolint:contextcheck return nil }, @@ -83,13 +90,6 @@ func New( return srv, nil } -// Run starts the server configuration, Sentry, and begins serving. -func (srv *Server) Run() { - srv.configure() - srv.enableSentry() - srv.serve() -} - // ServeHTTP delegates to the chi router. func (srv *Server) ServeHTTP( writer http.ResponseWriter, @@ -202,8 +202,6 @@ func (srv *Server) serveUntilShutdown() { Handler: srv, } - srv.SetupRoutes() - srv.log.Info( "http begin listen", "listenaddr", listenAddr, ) -- 2.54.0 From 729c671561ea8f1fd5ef9f8aaa280c3003e27388 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 04:13:07 +0200 Subject: [PATCH 02/40] Remove BUILDARCH and stamp the git version in Docker builds (closes #107, closes #109) The Makefile no longer passes a build architecture; it set a variable that does not exist, and the architecture is available at run time. A plain docker build now stamps neoircd with the tag or short commit: .dockerignore sends .git, without its config, and the builder takes the version from the VERSION build arg when given, otherwise from git describe. With .git present, an empty, dev or unknown version fails the build. Model: opus-5-5 --- .dockerignore | 3 ++- Dockerfile | 15 +++++++++++++-- Makefile | 3 +-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.dockerignore b/.dockerignore index 9b75277..3850038 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ -.git +# .git is sent so the build can stamp the version, without its config. +.git/config *.md !README.md neoircd diff --git a/Dockerfile b/Dockerfile index 32acda7..b90d0ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,8 +39,19 @@ COPY --from=web-builder /web/dist/ web/dist/ RUN make test # Build static binaries (no cgo needed at runtime — modernc.org/sqlite is pure Go) -ARG VERSION=dev -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /neoircd ./cmd/neoircd/ +# +# neoircd is stamped with the VERSION build arg when one is given, otherwise +# with the tag or short commit from the .git in the build context. With .git +# present, a version that is still empty, dev or unknown fails the build. +ARG VERSION +RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \ + if [ -e .git ]; then \ + case "$VERSION" in ""|dev|unknown) \ + echo "version is '$VERSION' although .git is present" >&2; \ + exit 1 ;; \ + esac; \ + fi; \ + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /neoircd ./cmd/neoircd/ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /neoirc-cli ./cmd/neoirc-cli/ # Runtime stage diff --git a/Makefile b/Makefile index f00e4b7..6758284 100644 --- a/Makefile +++ b/Makefile @@ -2,8 +2,7 @@ BINARY := neoircd VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") -BUILDARCH := $(shell go env GOARCH) -LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH) +LDFLAGS := -X main.Version=$(VERSION) all: check build -- 2.54.0 From e3324407cae3d9a497171e4ba1f78c4eef2ef54d Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 10:13:28 +0200 Subject: [PATCH 03/40] Fix the data races and the test timeout that fail make test (closes #104) The HTTP server built its router inside the goroutine that starts serving, so the start hook returned before the router existed, and the internal/handlers tests raced with it or hit a nil router. The start hook now runs configure, enableSentry and SetupRoutes, in that order, then serves in the background. The goroutine that sends queued messages to an IRC client read c.nick without c.mu while NICK changed it. Every such read now takes the lock. Under -race in the Docker build, internal/handlers takes over 30s on database work, not clock waits, so both go test runs in make test use -timeout 120s. The || retry stays (https://git.eeqj.de/sneak/neoirc/issues/101). Model: opus-5-5 --- Makefile | 2 +- internal/ircserver/relay.go | 48 ++++++++++++++++++++++++++++++------- internal/server/server.go | 18 +++++++------- 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/Makefile b/Makefile index 67ceb3e..f00e4b7 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ fmt-check: @test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1) test: ensure-web-dist - go test -timeout 30s -race -cover ./... || go test -timeout 30s -race -v ./... + go test -timeout 120s -race -cover ./... || go test -timeout 120s -race -v ./... # check runs all validation without making changes # Used by CI and Docker build — fails if anything is wrong diff --git a/internal/ircserver/relay.go b/internal/ircserver/relay.go index cb7d185..6d4195f 100644 --- a/internal/ircserver/relay.go +++ b/internal/ircserver/relay.go @@ -128,7 +128,11 @@ func (c *Conn) deliverIRCMessage( default: // Unknown command — deliver as server notice. if text != "" { - c.sendFromServer("NOTICE", c.nick, text) + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + + c.sendFromServer("NOTICE", nick, text) } } } @@ -158,8 +162,12 @@ func (c *Conn) deliverNumeric( _ = json.Unmarshal(msg.Params, ¶ms) } + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + allParams := make([]string, 0, 1+len(params)+1) - allParams = append(allParams, c.nick) + allParams = append(allParams, nick) allParams = append(allParams, params...) if text != "" { @@ -177,8 +185,12 @@ func (c *Conn) deliverTextMessage( from := msg.From target := msg.To + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + // Don't echo our own messages back. - if strings.EqualFold(from, c.nick) { + if strings.EqualFold(from, nick) { return } @@ -192,9 +204,13 @@ func (c *Conn) deliverTextMessage( // deliverJoin sends a JOIN notification. 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, c.nick) { + if strings.EqualFold(msg.From, nick) { return } @@ -206,7 +222,11 @@ func (c *Conn) deliverJoin(msg *db.IRCMessage) { // deliverPart sends a PART notification. func (c *Conn) deliverPart(msg *db.IRCMessage, text string) { - if strings.EqualFold(msg.From, c.nick) { + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + + if strings.EqualFold(msg.From, nick) { return } @@ -227,7 +247,11 @@ func (c *Conn) deliverNickChange( msg *db.IRCMessage, newNick string, ) { - if strings.EqualFold(msg.From, c.nick) { + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + + if strings.EqualFold(msg.From, nick) { return } @@ -241,7 +265,11 @@ func (c *Conn) deliverQuitMsg( msg *db.IRCMessage, text string, ) { - if strings.EqualFold(msg.From, c.nick) { + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + + if strings.EqualFold(msg.From, nick) { return } @@ -302,7 +330,11 @@ func (c *Conn) deliverInviteMsg( _ *db.IRCMessage, text string, ) { - c.sendFromServer("NOTICE", c.nick, text) + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + + c.sendFromServer("NOTICE", nick, text) } // deliverMode sends a MODE change notification. diff --git a/internal/server/server.go b/internal/server/server.go index 50216fa..1006342 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -71,7 +71,14 @@ func New( lifecycle.Append(fx.Hook{ OnStart: func(_ context.Context) error { srv.startupTime = time.Now() - go srv.Run() //nolint:contextcheck + + // Build the router before OnStart returns, so srv can + // handle requests as soon as the app has started. + srv.configure() + srv.enableSentry() + srv.SetupRoutes() + + go srv.serve() //nolint:contextcheck return nil }, @@ -83,13 +90,6 @@ func New( return srv, nil } -// Run starts the server configuration, Sentry, and begins serving. -func (srv *Server) Run() { - srv.configure() - srv.enableSentry() - srv.serve() -} - // ServeHTTP delegates to the chi router. func (srv *Server) ServeHTTP( writer http.ResponseWriter, @@ -202,8 +202,6 @@ func (srv *Server) serveUntilShutdown() { Handler: srv, } - srv.SetupRoutes() - srv.log.Info( "http begin listen", "listenaddr", listenAddr, ) -- 2.54.0 From 9a4642190216472e32fbd160b46520f8507625d0 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 08:31:01 +0200 Subject: [PATCH 04/40] Run make test once so a failing test fails the build (closes #101) make test retried a failed go test run with go test -v, and when the retry passed, make test, make check and the Docker build all reported success, hiding real failures. It now runs go test once, keeping -timeout 120s, -race and -cover. Model: opus-5-5 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 6758284..2aac9ec 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ fmt-check: @test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1) test: ensure-web-dist - go test -timeout 120s -race -cover ./... || go test -timeout 120s -race -v ./... + go test -timeout 120s -race -cover ./... # check runs all validation without making changes # Used by CI and Docker build — fails if anything is wrong -- 2.54.0 From bb7bcb31ea5e7c9d435af964532b8761ab47deaa Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 21:01:46 +0200 Subject: [PATCH 05/40] Re-vendor the canonical files from sneak/prompts at dd4027b (closes #112) The shared files are fetched from sneak/prompts dd4027b, with this repository's own entries after the shared content in .gitignore, .editorconfig and .dockerignore. Lint and tests are Dockerfile phases (golangci-lint v2.14.0, Debian Go 1.24.13) that the build stage depends on, and the Makefile targets call the script/ entrypoints. make fmt also formats Markdown with prettier. Fixes for the new lint findings: the auth cookie is always Secure, an IRC connection's relay goroutine stops when the connection closes, and repeated strings are constants. neoirc-cli treats a plain-HTTP server on localhost or a loopback address as secure, so local use keeps its session. Whether the 60-second test cap covers building the test phase is open on sneak/prompts issue 113. Model: opus-5-5 --- .dockerignore | 90 +- .editorconfig | 5 + .gitea/workflows/check.yml | 2 +- .gitignore | 47 +- .golangci.yml | 83 +- .prettierignore | 2 + .prettierrc | 4 + AGENTS.md | 10 +- CONVENTIONS.md | 36 +- Dockerfile | 54 +- Makefile | 44 +- README.md | 1849 +++++++++++++----------- REPO_POLICIES.md | 542 ++++++- internal/cli/api/client.go | 26 +- internal/cli/api/client_test.go | 131 ++ internal/db/errors.go | 1 - internal/db/queries.go | 34 +- internal/db/queries_test.go | 15 +- internal/handlers/api.go | 112 +- internal/handlers/api_test.go | 549 ++++--- internal/handlers/auth.go | 8 +- internal/handlers/handlers.go | 10 +- internal/hashcash/hashcash.go | 13 +- internal/ircserver/commands.go | 23 +- internal/ircserver/conn.go | 7 +- internal/ircserver/integration_test.go | 4 + internal/ircserver/parser_test.go | 68 +- internal/ircserver/server.go | 10 +- internal/ircserver/server_test.go | 62 +- internal/server/server.go | 4 +- internal/service/service.go | 57 +- package.json | 6 + pkg/irc/numerics.go | 3 +- schema/README.md | 91 +- script/bootstrap | 143 ++ script/check | 16 + script/cibuild | 28 + script/docker | 24 + script/fmt | 35 + script/fmt-check | 40 + script/install-precommit | 16 + script/lint | 23 + script/precommit | 23 + script/projectname | 12 + script/setup | 13 + script/test | 19 + yarn.lock | 8 + 47 files changed, 2975 insertions(+), 1427 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 internal/cli/api/client_test.go create mode 100644 package.json create mode 100755 script/bootstrap create mode 100755 script/check create mode 100755 script/cibuild create mode 100755 script/docker create mode 100755 script/fmt create mode 100755 script/fmt-check create mode 100755 script/install-precommit create mode 100755 script/lint create mode 100755 script/precommit create mode 100755 script/projectname create mode 100755 script/setup create mode 100755 script/test create mode 100644 yarn.lock diff --git a/.dockerignore b/.dockerignore index 3850038..83fe15e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,10 +1,80 @@ -# .git is sent so the build can stamp the version, without its config. -.git/config -*.md -!README.md -neoircd -neoirc-cli -data.db -data.db-wal -data.db-shm -.env +# .dockerignore does NOT use .gitignore semantics. Docker matches with +# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross +# `/` and an unprefixed pattern is anchored at the context root. Every +# depth-independent pattern therefore needs `**/`, or `config/.env` and +# `certs/server.key` still ship while this file reads as solved. Only +# genuinely root-anchored entries go unprefixed. Never transplant these +# into .gitignore, where `**/` is wrong. +# +# Matching is case-sensitive, so secrets use character ranges rather +# than an ALL-CAPS twin, which would still miss `Server.Key`. +# +# Extend with this repo's own host-built artifacts, written anchored: +# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and +# deletes the package directory from the context. + +# .git is sent without its config. Without a VERSION build argument the +# stage that compiles runs `git describe --tags --always` on .git, which +# does not need .git/config; that file can hold a credential, such as a +# password in a remote URL or the token the CI checkout step stores there. +# Each submodule keeps a config with the same exposure in its git directory +# under .git/modules/, nested again for a submodule's own submodules, or in +# its own .git directory when it keeps one. +# KNOWN GAP: a submodule whose name has a `config` segment (`config`, +# `deploy/config`, `config/lib`) loses its whole git directory, because +# `**/.git/modules/**/config` also matches that segment's directory +# under .git/modules/. Go's version stamping then fails the build; +# nothing leaks. Name such a submodule without that segment: +# `git submodule add --name`. +**/.git/config +**/.git/modules/**/config + +# Agent scratch: one full checkout of the repo per in-flight agent. +# Anchored because it occurs once where agents run at the repo root. +# KNOWN GAP: a repo running agents in subdirectories still ships +# `services/api/.claude/` and must add its own anchored entry. +.claude + +# Environment files. `*.env` covers bare `.env` and the `prod.env` +# convention. Re-include a committed template with a negation if the +# build needs one: `!docs/example.env`. +**/*.[eE][nN][vV] +**/.[eE][nN][vV].* +**/.[eE][nN][vV][rR][cC] + +# Private keys and the bundles carrying them. Public certificates +# (*.crt, *.cer) are deliberately absent: they are legitimate inputs. +**/*.[pP][eE][mM] +**/*.[kK][eE][yY] +**/*.[pP]12 +**/*.[pP][fF][xX] +**/[iI][dD]_[rR][sS][aA] +**/[iI][dD]_[dD][sS][aA] +**/[iI][dD]_[eE][cC][dD][sS][aA] +**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK] +**/[iI][dD]_[eE][dD]25519 +**/[iI][dD]_[eE][dD]25519_[sS][kK] + +# Dependencies: restored inside the image, never copied in. +**/node_modules + +# OS metadata. +**/.DS_Store +**/Thumbs.db + +# Editor state: never a build input, and it churns COPY. +**/*.swp +**/*.swo +**/*~ +**/*.bak +**/.idea +**/.vscode +**/*.sublime-* + +# This repository's host-built artifacts: the binaries, and the database +# a local run writes. +/neoircd +/neoirc-cli +/data.db +/data.db-wal +/data.db-shm diff --git a/.editorconfig b/.editorconfig index 2fe0ce0..b8d5a99 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,3 +10,8 @@ insert_final_newline = true [Makefile] indent_style = tab + +# This repository's own entries, after the shared content above. + +[*.go] +indent_style = tab diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index aca7a51..ee73864 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -6,4 +6,4 @@ jobs: steps: # actions/checkout v4.2.2, 2026-02-22 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - run: docker build . + - run: script/cibuild diff --git a/.gitignore b/.gitignore index 745e210..823d722 100644 --- a/.gitignore +++ b/.gitignore @@ -11,19 +11,48 @@ Thumbs.db .vscode/ *.sublime-* +# Agent scratch (worktrees of this repo, created and destroyed by +# in-flight tooling). Unanchored: .gitignore patterns already match at +# every depth, so no prefix is wanted here. This is not a .dockerignore +# entry and must not be given a `**/` prefix on the way into one. +.claude/ + # Node node_modules/ -# Environment / secrets -.env -.env.* -*.pem -*.key +# Secrets. Unanchored like every entry above, so each matches at every +# depth. Matching is case-sensitive on Linux, so names use character +# ranges rather than a lowercase form that misses `Server.Key`. + +# Environment files. `*.env` covers bare `.env` and the `prod.env` +# convention. Only the templates `example.env` and `sample.env` are +# re-included below. A repository that commits any other template adds +# its own negation after these lines, for example `!.env.example`. +*.[eE][nN][vV] +.[eE][nN][vV].* +.[eE][nN][vV][rR][cC] +!example.env +!sample.env + +# Private keys and the bundles carrying them. +*.[pP][eE][mM] +*.[kK][eE][yY] +*.[pP]12 +*.[pP][fF][xX] +[iI][dD]_[rR][sS][aA] +[iI][dD]_[dD][sS][aA] +[iI][dD]_[eE][cC][dD][sS][aA] +[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK] +[iI][dD]_[eE][dD]25519 +[iI][dD]_[eE][dD]25519_[sS][kK] + +# This repository's own entries, after the shared content above. # Build artifacts web/dist/ -/neoircd /bin/ +/neoircd +/neoirc-cli *.exe *.dll *.so @@ -32,8 +61,6 @@ web/dist/ *.out vendor/ -# Project +# Local database and logs data.db -debug.log -/neoirc-cli -web/node_modules/ +*.log diff --git a/.golangci.yml b/.golangci.yml index 2698d2d..1b73eb9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,13 +1,30 @@ version: "2" +# Config schema uses the golangci-lint v2 layout (settings live under +# linters.settings, not top-level linters-settings) so that the +# thresholds below are actually applied by golangci-lint >= v2. + run: timeout: 5m modules-download-mode: readonly linters: default: all + enable: + # Successor to the deprecated gomodguard. Named explicitly, rather than + # left to `default: all`, because it carries the module policy below. + - gomodguard_v2 disable: - - wsl # Deprecated in v2, replaced by wsl_v5 + # Genuinely incompatible with project patterns + - exhaustruct # Requires all struct fields + - exhaustruct_v5 # Requires all struct fields (successor to exhaustruct) + - godot # Requires comments to end with periods + - wrapcheck # Too verbose for internal packages + - varnamelen # Short names like db, id are idiomatic Go + # Deprecated: the warning is attached to the old name, so it is + # silenced by disabling that name, not by enabling the successor. + - wsl # Deprecated, replaced by wsl_v5 + - gomodguard # Deprecated, replaced by gomodguard_v2 settings: lll: line-length: 88 @@ -18,19 +35,65 @@ linters: max-complexity: 15 dupl: threshold: 100 - gosec: - excludes: - - G704 depguard: + # Test-support code must not be compiled into the shipped binary. A + # test-support package exists to hand a test privileges the program + # itself must never have, so a file that is not a test must not import + # one. Test files, and the files inside a package whose directory name + # ends in `test`, are where that code belongs, and are exempt. + # + # The deny list below is the one part of this file a repository is + # expected to extend, and the only part it may. depguard matches an + # import path against a list of prefixes, so it cannot be told "any path + # whose last segment ends in test"; a repository's own test-support + # packages have to be named here one at a time, by full import path, + # under a module path that differs from repository to repository. Add + # them; change nothing else. rules: - all: + test-support: + list-mode: lax + files: + - "$all" + - "!$test" + - "!**/*test/**" deny: - - pkg: "io/ioutil" - desc: "Deprecated; use io and os packages." - - pkg: "math/rand$" - desc: "Use crypto/rand for security-sensitive code." + - pkg: net/http/httptest + desc: >- + Test-support code belongs in test files and in packages whose + directory name ends in test, not in the shipped binary. + # Only decisions already recorded in the Go package defaults are + # listed here. Every entry matches the module path exactly. + gomodguard_v2: + blocked: + - module: github.com/rs/zerolog + recommendations: + - log/slog + reason: "Structured logging is stdlib log/slog." + # One entry per pre-fork module path, because the later releases + # are separate paths. A prefix match would be shorter but would + # also reach github.com/go-redis/redismock, the test double for + # the successor these entries recommend. + - module: github.com/go-redis/redis + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/go-redis/redis/v7 + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/go-redis/redis/v8 + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/sergi/go-diff + recommendations: + - github.com/aymanbagabas/go-udiff + reason: "No unified diff output; use go-udiff." + - module: github.com/hexops/gotextdiff + recommendations: + - github.com/aymanbagabas/go-udiff + reason: "Unmaintained fork; use go-udiff." issues: - exclude-use-default: false max-issues-per-linter: 0 max-same-issues: 0 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..23d67fc --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +node_modules/ +yarn.lock diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8af31cd --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 4, + "proseWrap": "always" +} diff --git a/AGENTS.md b/AGENTS.md index 69ff0a4..d189eb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,10 +2,12 @@ ## Before Every Commit -1. **Format**: `gofmt -s -w .` and `goimports -w .` -2. **Lint**: `golangci-lint run --config .golangci.yml ./...` — zero issues -3. **Test**: `go test -race ./...` — all passing -4. **Build**: `go build ./cmd/neoircd` — compiles clean +1. **Format**: `make fmt` +2. **Check**: `make check` — the tests and the linter, which run in Docker, and + the format check: all passing, zero issues + +Never run `go test`, `golangci-lint` or `gofmt` directly; use the `make` +targets. No commit lands on main with lint errors, test failures, or formatting issues. diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 8885442..05d6b19 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -1,6 +1,8 @@ # Go HTTP Server Conventions -This document defines the architectural patterns, design decisions, and conventions for building Go HTTP servers. All new projects must follow these standards. +This document defines the architectural patterns, design decisions, and +conventions for building Go HTTP servers. All new projects must follow these +standards. ## Table of Contents @@ -84,10 +86,11 @@ project-root/ ### Key Principles -- **`cmd/{appname}/`**: Only the entry point. Minimal logic, just bootstrapping. -- **`internal/`**: All application packages. Not importable by external projects. -- **One package per concern**: config, database, handlers, middleware, etc. -- **Flat handler files**: One file per handler or logical group of handlers. +- **`cmd/{appname}/`**: Only the entry point. Minimal logic, just bootstrapping. +- **`internal/`**: All application packages. Not importable by external + projects. +- **One package per concern**: config, database, handlers, middleware, etc. +- **Flat handler files**: One file per handler or logical group of handlers. --- @@ -188,7 +191,8 @@ Providers are resolved automatically by fx, but conceptually follow this order: 2. `logger.New` - Logger (depends on Globals) 3. `config.New` - Configuration (depends on Globals, Logger) 4. `database.New` - Database (depends on Logger, Config) -5. `healthcheck.New` - Health check (depends on Globals, Config, Logger, Database) +5. `healthcheck.New` - Health check (depends on Globals, Config, Logger, + Database) 6. `middleware.New` - Middleware (depends on Logger, Globals, Config) 7. `handlers.New` - Handlers (depends on Logger, Globals, Database, Healthcheck) 8. `server.New` - Server (depends on all above) @@ -452,11 +456,11 @@ func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) { ### Closure-Based Handler Pattern For JSON route handlers, both the request and the response structures are -defined in the scope of the method that returns the HandlerFunc. They can -be called simply `Request` and `Response` or slightly more descriptive -names. +defined in the scope of the method that returns the HandlerFunc. They can be +called simply `Request` and `Response` or slightly more descriptive names. -All handlers return `http.HandlerFunc` using the closure pattern. This allows initialization logic to run once when the handler is created: +All handlers return `http.HandlerFunc` using the closure pattern. This allows +initialization logic to run once when the handler is created: ```go // internal/handlers/index.go @@ -517,10 +521,11 @@ func (s *Handlers) decodeJSON(w http.ResponseWriter, r *http.Request, v interfac ### Handler Naming Convention -- `HandleIndex()` - Main page -- `HandleLoginGET()` / `HandleLoginPOST()` - Form handlers with HTTP method suffix -- `HandleNow()` - API endpoints -- `HandleHealthCheck()` - System endpoints +- `HandleIndex()` - Main page +- `HandleLoginGET()` / `HandleLoginPOST()` - Form handlers with HTTP method + suffix +- `HandleNow()` - API endpoints +- `HandleHealthCheck()` - System endpoints --- @@ -741,7 +746,8 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) { 1. **Environment variables** (highest priority via `AutomaticEnv()`) 2. **`.env` file** (loaded via `godotenv/autoload` import) -3. **Config files**: `/etc/{appname}/{appname}.yaml`, `~/.config/{appname}/{appname}.yaml` +3. **Config files**: `/etc/{appname}/{appname}.yaml`, + `~/.config/{appname}/{appname}.yaml` 4. **Defaults** (lowest priority) ### Environment Loading diff --git a/Dockerfile b/Dockerfile index b90d0ca..371400a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,41 +8,56 @@ COPY web/src/ src/ COPY web/build.sh build.sh RUN sh build.sh -# Lint stage — fast feedback on formatting and lint issues -# golangci/golangci-lint:v2.1.6, 2026-03-02 -FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint +# Lint phase, built alone by script/lint. The linter is invoked directly +# rather than through `make lint`, which is itself a docker build and +# would recurse into a daemon that does not exist in a build step. +# golangci/golangci-lint:v2.14.0, 2026-10-06 +FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -# Create placeholder files so //go:embed dist/* in web/embed.go resolves -# without depending on the web-builder stage (lint should fail fast) +# Placeholder files so //go:embed dist/* in web/embed.go resolves +# without waiting for the web-builder stage. The test phase does the same. RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js -RUN make fmt-check -RUN make lint +RUN golangci-lint run --config .golangci.yml ./... -# Build stage +# Test phase, built alone by script/test. -race needs cgo and so a C +# compiler, which the Debian Go image ships and the alpine one does not. +# golang:1.24.13-bookworm, 2026-10-06 +FROM golang@sha256:1a6d4452c65dea36aac2e2d606b01b4a029ec90cc1ae53890540ce6173ea77ac AS test +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js +# -p 4 because test runs on a shared build host cap their parallelism. +RUN go test -p 4 -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -p 4 -timeout 90s -race -v ./...; exit 1; } + +# Build stage. Nothing is wanted from either phase above; the copies +# are what make BuildKit build them first, so this stage cannot run +# unless lint and test passed. # golang:1.24-alpine, 2026-02-26 FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder -WORKDIR /src -RUN apk add --no-cache git build-base make - -# Force BuildKit to run the lint stage before proceeding COPY --from=lint /src/go.sum /dev/null - +COPY --from=test /src/go.sum /dev/null +RUN apk add --no-cache git +# A tar-stream context keeps the sender's file owners, which git refuses. +RUN git config --system --add safe.directory /src +WORKDIR /src COPY go.mod go.sum ./ RUN go mod download - COPY . . COPY --from=web-builder /web/dist/ web/dist/ -RUN make test - # Build static binaries (no cgo needed at runtime — modernc.org/sqlite is pure Go) # # neoircd is stamped with the VERSION build arg when one is given, otherwise -# with the tag or short commit from the .git in the build context. With .git -# present, a version that is still empty, dev or unknown fails the build. +# with `git describe --tags --always` on the .git in the build context. With +# .git present, a version that is still empty, dev or unknown fails the build: +# git is missing or could not read the checkout. ARG VERSION RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \ if [ -e .git ]; then \ @@ -54,7 +69,8 @@ RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /neoircd ./cmd/neoircd/ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /neoirc-cli ./cmd/neoirc-cli/ -# Runtime stage +# Runtime stage, and the last one: a plain `docker build .` builds this +# stage's chain and nothing else. # alpine:3.21, 2026-02-26 FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 RUN apk add --no-cache ca-certificates \ diff --git a/Makefile b/Makefile index 2aac9ec..d08cc41 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,25 @@ -.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks ensure-web-dist +.PHONY: all bootstrap setup build lint fmt fmt-check test check clean run debug docker hooks ensure-web-dist BINARY := neoircd VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") LDFLAGS := -X main.Version=$(VERSION) +# The standard targets are thin shims; the implementations live in +# script/ per the scripts-to-rule-them-all pattern (see the Entrypoints +# section of README.md). + all: check build +bootstrap: + @script/bootstrap + +setup: + @script/setup + # ensure-web-dist creates placeholder files so //go:embed dist/* in # web/embed.go resolves without a full Node.js build. The real SPA is # built by the web-builder Docker stage; these placeholders let -# "make test" and "make build" work outside Docker. +# "make build" work outside Docker. ensure-web-dist: @if [ ! -d web/dist ]; then \ mkdir -p web/dist && \ @@ -20,25 +30,20 @@ ensure-web-dist: build: ensure-web-dist go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd -lint: ensure-web-dist - golangci-lint run --config .golangci.yml ./... +test: + @script/test + +lint: + @script/lint fmt: - gofmt -s -w . - goimports -w . + @script/fmt fmt-check: - @test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1) + @script/fmt-check -test: ensure-web-dist - go test -timeout 120s -race -cover ./... - -# check runs all validation without making changes -# Used by CI and Docker build — fails if anything is wrong -check: test lint fmt-check - @echo "==> Building..." - go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/neoircd - @echo "==> All checks passed!" +check: + @script/check run: build ./bin/$(BINARY) @@ -50,10 +55,7 @@ clean: rm -rf bin/ neoircd docker: - docker build -t neoirc . + @script/docker hooks: - @printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit - @printf 'go mod tidy\ngo fmt ./...\ngit diff --exit-code -- go.mod go.sum || { echo "go mod tidy changed files; please stage and retry"; exit 1; }\n' >> .git/hooks/pre-commit - @printf 'make check\n' >> .git/hooks/pre-commit - @chmod +x .git/hooks/pre-commit + @script/install-precommit diff --git a/README.md b/README.md index fb97988..e1ed939 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ connections, enabling mobile-friendly persistent sessions over plain HTTP. The **HTTP API is the primary interface**. It's designed to be simple enough that writing a terminal IRC-style client against it is straightforward — just -`curl` and `jq` get you surprisingly far. The server also ships an embedded -web client as a convenience/reference implementation, but the API comes first. +`curl` and `jq` get you surprisingly far. The server also ships an embedded web +client as a convenience/reference implementation, but the API comes first. --- @@ -28,6 +28,7 @@ web client as a convenience/reference implementation, but the API comes first. - [Storage](#storage) - [Configuration](#configuration) - [IRC Protocol Listener](#irc-protocol-listener) +- [Entrypoints](#entrypoints) - [Deployment](#deployment) - [Client Development Guide](#client-development-guide) - [Rate Limiting & Abuse Prevention](#rate-limiting--abuse-prevention) @@ -42,8 +43,8 @@ web client as a convenience/reference implementation, but the API comes first. ## Motivation IRC is in decline because session state is tied to the TCP connection. In a -mobile-first world, that's a nonstarter. Not everyone wants to run a bouncer -or pay for IRCCloud. +mobile-first world, that's a nonstarter. Not everyone wants to run a bouncer or +pay for IRCCloud. This project builds a server that: @@ -64,8 +65,8 @@ is too complex. ## Why Not Just Use IRC / XMPP / Matrix? This isn't a new protocol that borrows IRC terminology for familiarity. This -**is** IRC — the same command model, the same semantics, the same numeric -reply codes from RFC 1459/2812 — carried over HTTP+JSON instead of raw TCP. +**is** IRC — the same command model, the same semantics, the same numeric reply +codes from RFC 1459/2812 — carried over HTTP+JSON instead of raw TCP. The question isn't "why build something new?" It's "what's the minimum set of changes to make IRC work on modern devices?" The answer turned out to be four @@ -83,13 +84,13 @@ client. No custom protocol parsers, no connection state machines. ### 2. Server-held session state -In IRC, the TCP connection *is* the session. Disconnect and you're gone — your -nick is released, you leave all channels, messages sent while you're offline -are lost forever. This is IRC's fundamental mobile problem. +In IRC, the TCP connection _is_ the session. Disconnect and you're gone — your +nick is released, you leave all channels, messages sent while you're offline are +lost forever. This is IRC's fundamental mobile problem. Here, sessions persist independently of connections. Your nick, channel -memberships, and message queue survive disconnects. Multiple devices can share -a session simultaneously, each with its own delivery queue. +memberships, and message queue survive disconnects. Multiple devices can share a +session simultaneously, each with its own delivery queue. ### 3. Structured message bodies @@ -105,9 +106,9 @@ wire representation is ambiguous. ### 4. Key/value metadata on messages The `meta` field on every message envelope carries extensible attributes — -cryptographic signatures, content hashes, whatever clients want to attach. -IRC has no equivalent; bolting signatures onto IRC requires out-of-band -mechanisms or stuffing data into CTCP. +cryptographic signatures, content hashes, whatever clients want to attach. IRC +has no equivalent; bolting signatures onto IRC requires out-of-band mechanisms +or stuffing data into CTCP. ### What didn't change @@ -115,22 +116,22 @@ Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`, `KICK`, `353`, `433` — same commands, same semantics. Channels start with `#`. Joining a nonexistent channel creates it. Channels disappear when empty. Nicks are unique per server. Identity starts with a key — a nick is a display name. -Accounts are optional: you can create an anonymous session instantly, or -set a password via the PASS command for multi-client access to a single session. +Accounts are optional: you can create an anonymous session instantly, or set a +password via the PASS command for multi-client access to a single session. ### On the resemblance to JSON-RPC All C2S commands go through `POST /api/v1/messages` with a `command` field that dispatches the action. This looks like JSON-RPC, but the resemblance is incidental. It's IRC's command model — `PRIVMSG #channel :hello` becomes -`{"command": "PRIVMSG", "to": "#channel", "body": ["hello"]}` — encoded as -JSON rather than space-delimited text. The command vocabulary is IRC's, not -an invention. +`{"command": "PRIVMSG", "to": "#channel", "body": ["hello"]}` — encoded as JSON +rather than space-delimited text. The command vocabulary is IRC's, not an +invention. -The message envelope is deliberately identical for C2S and S2C. A `PRIVMSG` is -a `PRIVMSG` regardless of direction. A `JOIN` from a client is the same shape -as the `JOIN` relayed to channel members. This keeps the protocol simple and -makes signing consistent — you sign the same structure you send. +The message envelope is deliberately identical for C2S and S2C. A `PRIVMSG` is a +`PRIVMSG` regardless of direction. A `JOIN` from a client is the same shape as +the `JOIN` relayed to channel members. This keeps the protocol simple and makes +signing consistent — you sign the same structure you send. ### Why not XMPP or Matrix? @@ -158,26 +159,27 @@ for multi-client access. #### Session Creation -- **Session creation**: client sends `POST /api/v1/session` with a desired - nick → server sets an **HttpOnly auth cookie** (`neoirc_auth`) containing - a cryptographically random value (64 hex characters) and returns the user - ID and nick in the JSON response body. No auth credential appears in the - JSON body. -- The auth cookie is HttpOnly, SameSite=Strict, and Secure when behind TLS. - Browsers handle cookies automatically. **CLI clients (curl, custom HTTP - clients) must explicitly save and send cookies** — e.g., using curl's - `-c`/`-b` flags or an HTTP cookie jar in their language's HTTP library. -- Sessions start anonymous — no password required. When the session expires - or the user QUITs, the nick is released. +- **Session creation**: client sends `POST /api/v1/session` with a desired nick + → server sets an **HttpOnly auth cookie** (`neoirc_auth`) containing a + cryptographically random value (64 hex characters) and returns the user ID and + nick in the JSON response body. No auth credential appears in the JSON body. +- The auth cookie is HttpOnly, SameSite=Strict, and Secure: clients send it only + over HTTPS, which the TLS-terminating reverse proxy provides, or to a server + on `localhost` (see [Transport Security](#transport-security)). Browsers + handle cookies automatically. **CLI clients (curl, custom HTTP clients) must + explicitly save and send cookies** — e.g., using curl's `-c`/`-b` flags or an + HTTP cookie jar in their language's HTTP library. +- Sessions start anonymous — no password required. When the session expires or + the user QUITs, the nick is released. #### Setting a Password (Optional, for Multi-Client Access) For users who want to access the same session from multiple devices: - **Set password via IRC PASS command**: the authenticated client sends - `POST /api/v1/messages` with `{"command":"PASS","body":["mypassword"]}`. - The server hashes the password with bcrypt and stores it on the session. - Password must be at least 8 characters. + `POST /api/v1/messages` with `{"command":"PASS","body":["mypassword"]}`. The + server hashes the password with bcrypt and stores it on the session. Password + must be at least 8 characters. - **Login from another client**: `POST /api/v1/login` with nick and password → server verifies the password, creates a new client for the existing session, and sets an auth cookie. Channel memberships and message queues are shared. @@ -195,17 +197,16 @@ For users who want to access the same session from multiple devices: authority on cookie validity. **Rationale:** IRC has no accounts. You connect, pick a nick, and talk. -Anonymous sessions preserve that simplicity — instant access, zero friction. -But some users want to access the same session from multiple devices without -a bouncer. The PASS command enables multi-client login without adding friction -for casual users: if you don't need multi-client, just create a session and -go. Cookie-based auth simplifies credential management — browsers handle -cookies automatically, and CLI clients just need a cookie jar (e.g., curl's -`-c`/`-b` flags). Note: both anonymous -and password-protected sessions are deleted when the last client disconnects -(QUIT or logout). Identity verification at the message layer via cryptographic -signatures (see [Security Model](#security-model)) remains independent of -password status. +Anonymous sessions preserve that simplicity — instant access, zero friction. But +some users want to access the same session from multiple devices without a +bouncer. The PASS command enables multi-client login without adding friction for +casual users: if you don't need multi-client, just create a session and go. +Cookie-based auth simplifies credential management — browsers handle cookies +automatically, and CLI clients just need a cookie jar (e.g., curl's `-c`/`-b` +flags). Note: both anonymous and password-protected sessions are deleted when +the last client disconnects (QUIT or logout). Identity verification at the +message layer via cryptographic signatures (see +[Security Model](#security-model)) remains independent of password status. ### Hostmask (nick!user@host) @@ -219,19 +220,19 @@ Each session has an IRC-style hostmask composed of three parts: - **ip** — the real IP address of the session creator, extracted from `X-Forwarded-For`, `X-Real-IP`, or `RemoteAddr` -Each **client connection** (created at session creation or login) -also stores its own **ip** and **hostname**, allowing the server to track the -network origin of each individual client independently from the session. -Client-level IP and hostname are **not displayed to regular users**. They are -only visible to **server operators** (o-line) via `RPL_WHOISACTUALLY` (338) -when the oper performs a WHOIS on a user. +Each **client connection** (created at session creation or login) also stores +its own **ip** and **hostname**, allowing the server to track the network origin +of each individual client independently from the session. Client-level IP and +hostname are **not displayed to regular users**. They are only visible to +**server operators** (o-line) via `RPL_WHOISACTUALLY` (338) when the oper +performs a WHOIS on a user. The hostmask appears in: - **WHOIS** (`311 RPL_WHOISUSER`) — `params` contains `[nick, username, hostname, "*"]` -- **WHOIS (oper-only)** (`338 RPL_WHOISACTUALLY`) — when the querier is a - server operator, includes the target's current client IP and hostname +- **WHOIS (oper-only)** (`338 RPL_WHOISACTUALLY`) — when the querier is a server + operator, includes the target's current client IP and hostname - **WHO** (`352 RPL_WHOREPLY`) — `params` contains `[channel, username, hostname, server, nick, flags]` @@ -242,14 +243,14 @@ The hostmask format (`nick!user@host`) is stored for future use in ban matching - Nicks are **unique per server at any point in time** — two sessions cannot hold the same nick simultaneously. -- Nicks are **case-sensitive** (unlike traditional IRC). `Alice` and `alice` - are different nicks. -- Nick length: 1–32 characters. No further character restrictions in the - current implementation. +- Nicks are **case-sensitive** (unlike traditional IRC). `Alice` and `alice` are + different nicks. +- Nick length: 1–32 characters. No further character restrictions in the current + implementation. - Nicks are **released when a session is destroyed** (via `QUIT` command or session expiry). There is no nick registration or reservation system. -- Nick changes are broadcast to all users sharing a channel with the changer, - as a `NICK` event message. +- Nick changes are broadcast to all users sharing a channel with the changer, as + a `NICK` event message. **Rationale:** IRC nick semantics, simplified. Case-insensitive nick comparison is a perpetual source of IRC bugs (different servers use different case-folding @@ -274,11 +275,10 @@ User Session └── Client C (cookie_c, queue_c) ``` -**Multi-client via login:** The `POST /api/v1/login` endpoint adds a new -client to an existing session (one that has a password set via PASS command), -enabling true multi-client support (multiple cookies sharing one nick/session -with independent message queues). Sessions without a password cannot be -logged into. +**Multi-client via login:** The `POST /api/v1/login` endpoint adds a new client +to an existing session (one that has a password set via PASS command), enabling +true multi-client support (multiple cookies sharing one nick/session with +independent message queues). Sessions without a password cannot be logged into. **Rationale:** The fundamental IRC mobile problem is that you can't have your phone and laptop connected simultaneously without a bouncer. Server-side @@ -326,9 +326,9 @@ The server implements HTTP long-polling for real-time message delivery: 2. If messages are immediately available, server responds instantly 3. If no messages are available, server holds the connection open 4. Server responds when either: - - A message arrives for this client (via the in-memory broker) - - The timeout expires (returns empty array) - - The client disconnects (connection closed, no response needed) + - A message arrives for this client (via the in-memory broker) + - The timeout expires (returns empty array) + - The client disconnects (connection closed, no response needed) **Implementation detail:** The server maintains an in-memory broker with per-client notification channels. When a message is enqueued for a client, the @@ -337,8 +337,8 @@ long-poll handlers. This is O(1) notification — no polling loops, no database scanning. **Timeout limits:** The server caps the `timeout` parameter at 30 seconds. -Clients should use 15 seconds as the default. The HTTP write timeout is set -to 60 seconds to accommodate long-poll connections. +Clients should use 15 seconds as the default. The HTTP write timeout is set to +60 seconds to accommodate long-poll connections. **Rationale:** Long-polling over HTTP is the simplest real-time transport that works everywhere. WebSockets add connection state, require different proxy @@ -355,18 +355,18 @@ balancer, and CDN handles it correctly. - **Ephemeral** — channels disappear when the last member leaves. There is no persistent channel registration. - **No channel size limits** in the current implementation. -- **Channel names** must start with `#`. If a client sends a `JOIN` without - the `#` prefix, the server adds it. +- **Channel names** must start with `#`. If a client sends a `JOIN` without the + `#` prefix, the server adds it. - **No channel-level encryption** — encryption is per-message via the `meta` field. ### Direct Messages (DMs) -- DMs are addressed by **nick at send time** — the server resolves the nick - to a user ID internally. +- DMs are addressed by **nick at send time** — the server resolves the nick to a + user ID internally. - DMs are **fan-out to both sender and recipient** — the sender sees their own - DM echoed back in their message queue, enabling multi-client consistency - (your laptop sees DMs you sent from your phone). + DM echoed back in their message queue, enabling multi-client consistency (your + laptop sees DMs you sent from your phone). - DM history is stored in the `messages` table with the recipient nick as the `msg_to` field. This means DM history is queryable per-nick, but if a user changes their nick, old DMs are associated with the old nick. @@ -379,28 +379,29 @@ All messages are JSON. No CBOR, no protobuf, no MessagePack, no custom binary framing. **Rationale:** JSON is human-readable, universally supported, and debuggable -with `curl | jq`. Binary formats save bandwidth at the cost of debuggability -and ecosystem compatibility. Chat messages are small — the overhead of JSON -over binary is measured in bytes per message, not meaningful bandwidth. The +with `curl | jq`. Binary formats save bandwidth at the cost of debuggability and +ecosystem compatibility. Chat messages are small — the overhead of JSON over +binary is measured in bytes per message, not meaningful bandwidth. The canonicalization story (RFC 8785 JCS) is also well-defined for JSON, which matters for signing. ### Why Opaque Cookies Instead of JWTs -JWTs encode claims that clients can decode and potentially rely on. This -creates a coupling between token format and client behavior. If the server -needs to revoke a token, change the expiry model, or add/remove claims, JWT -clients may break or behave incorrectly. +JWTs encode claims that clients can decode and potentially rely on. This creates +a coupling between token format and client behavior. If the server needs to +revoke a token, change the expiry model, or add/remove claims, JWT clients may +break or behave incorrectly. Opaque auth cookies are simpler: -- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash → - sets raw hex as an HttpOnly cookie + +- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash → sets + raw hex as an HttpOnly cookie - On each request, server hashes the cookie value and looks it up - Revocation is a database delete (cookie becomes invalid immediately) - No clock skew issues, no algorithm confusion, no "none" algorithm attacks - Cookie format can change without breaking clients -- Browsers and HTTP cookie jars manage cookies automatically; CLI clients - must explicitly save and resend cookies (e.g., curl `-c`/`-b` flags) +- Browsers and HTTP cookie jars manage cookies automatically; CLI clients must + explicitly save and resend cookies (e.g., curl `-c`/`-b` flags) --- @@ -509,16 +510,16 @@ Each message is stored ONCE. One queue entry per recipient client. ``` The `client_queues` table contains `(client_id, message_id)` pairs. When a -client polls with `GET /messages?after=`, the server queries for -queue entries with `id > after` for that client, joins against the messages -table, and returns the results. The `queue_id` (auto-incrementing primary -key of `client_queues`) serves as a monotonically increasing cursor. +client polls with `GET /messages?after=`, the server queries for queue +entries with `id > after` for that client, joins against the messages table, and +returns the results. The `queue_id` (auto-incrementing primary key of +`client_queues`) serves as a monotonically increasing cursor. ### In-Memory Broker -The server maintains an in-memory notification broker to avoid database -polling. The broker is a map of `client_id → []chan struct{}`. When a message -is enqueued for a client: +The server maintains an in-memory notification broker to avoid database polling. +The broker is a map of `client_id → []chan struct{}`. When a message is enqueued +for a client: 1. The handler calls `broker.Notify(clientID)` 2. The broker closes all waiting channels for that client @@ -554,41 +555,41 @@ the same JSON envelope: #### Field Reference -| Field | Type | C2S | S2C | Description | -|-----------|---------------------|-----------|-----------|-------------| -| `id` | string (UUID v4) | Ignored | Always | Server-assigned unique message identifier. | -| `command` | string | Required | Always | IRC command name (`PRIVMSG`, `JOIN`, etc.) or 3-digit numeric reply code (`001`, `433`, etc.). Case-insensitive on input; server normalizes to uppercase. | -| `from` | string | Ignored | Usually | Sender's nick (for user messages) or server name (for server messages). Server always overwrites this field — clients cannot spoof the sender. | -| `to` | string | Usually | Usually | Destination: `#channel` for channel targets, bare nick for DMs/user targets. | -| `params` | array of strings | Sometimes | Sometimes | Additional IRC-style positional parameters. Used by commands like `MODE`, `KICK`, and numeric replies like `353` (NAMES). | -| `body` | array or object | Usually | Usually | Structured message body. For text messages: array of strings (one per line). For structured data (e.g., `PUBKEY`): JSON object. **Never a raw string.** | -| `ts` | string (ISO 8601) | Ignored | Always | Server-assigned timestamp in RFC 3339 / ISO 8601 format with nanosecond precision. Example: `"2026-02-10T20:00:00.000000000Z"`. Always UTC. | -| `meta` | object | Optional | If present | Extensible metadata. Used for cryptographic signatures (`meta.sig`, `meta.alg`), hashcash proof-of-work (`meta.hashcash`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim except for `hashcash` which is validated on channels with `+H` mode. | +| Field | Type | C2S | S2C | Description | +| --------- | ----------------- | --------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | string (UUID v4) | Ignored | Always | Server-assigned unique message identifier. | +| `command` | string | Required | Always | IRC command name (`PRIVMSG`, `JOIN`, etc.) or 3-digit numeric reply code (`001`, `433`, etc.). Case-insensitive on input; server normalizes to uppercase. | +| `from` | string | Ignored | Usually | Sender's nick (for user messages) or server name (for server messages). Server always overwrites this field — clients cannot spoof the sender. | +| `to` | string | Usually | Usually | Destination: `#channel` for channel targets, bare nick for DMs/user targets. | +| `params` | array of strings | Sometimes | Sometimes | Additional IRC-style positional parameters. Used by commands like `MODE`, `KICK`, and numeric replies like `353` (NAMES). | +| `body` | array or object | Usually | Usually | Structured message body. For text messages: array of strings (one per line). For structured data (e.g., `PUBKEY`): JSON object. **Never a raw string.** | +| `ts` | string (ISO 8601) | Ignored | Always | Server-assigned timestamp in RFC 3339 / ISO 8601 format with nanosecond precision. Example: `"2026-02-10T20:00:00.000000000Z"`. Always UTC. | +| `meta` | object | Optional | If present | Extensible metadata. Used for cryptographic signatures (`meta.sig`, `meta.alg`), hashcash proof-of-work (`meta.hashcash`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim except for `hashcash` which is validated on channels with `+H` mode. | **Important invariants:** -- `body` is **always** an array or object, **never** a raw string. This - enables deterministic canonicalization via RFC 8785 JCS. +- `body` is **always** an array or object, **never** a raw string. This enables + deterministic canonicalization via RFC 8785 JCS. - `from` is **always set by the server** on S2C messages. Clients may include `from` on C2S messages, but it is ignored and overwritten. - `id` and `ts` are **always set by the server**. Client-supplied values are ignored. -- `meta` is **relayed verbatim**. The server stores it as-is and includes it - in S2C messages. It is never modified, validated, or interpreted by the - server. +- `meta` is **relayed verbatim**. The server stores it as-is and includes it in + S2C messages. It is never modified, validated, or interpreted by the server. ### Commands (C2S and S2C) All commands use the same envelope format regardless of direction. A `PRIVMSG` -from a client to the server has the same shape as the `PRIVMSG` relayed from -the server to other clients. The only differences are which fields the server -fills in (`id`, `ts`, `from`). +from a client to the server has the same shape as the `PRIVMSG` relayed from the +server to other clients. The only differences are which fields the server fills +in (`id`, `ts`, `from`). #### PRIVMSG — Send Message Send a message to a channel or user. This is the primary messaging command. **C2S:** + ```json {"command": "PRIVMSG", "to": "#general", "body": ["hello world"]} {"command": "PRIVMSG", "to": "#general", "body": ["line one", "line two"]} @@ -598,22 +599,23 @@ Send a message to a channel or user. This is the primary messaging command. ``` **S2C (as delivered to recipients):** + ```json { - "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5", - "command": "PRIVMSG", - "from": "alice", - "to": "#general", - "body": ["hello world"], - "ts": "2026-02-10T20:00:00.000000000Z", - "meta": {} + "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5", + "command": "PRIVMSG", + "from": "alice", + "to": "#general", + "body": ["hello world"], + "ts": "2026-02-10T20:00:00.000000000Z", + "meta": {} } ``` **Behavior:** -- If `to` starts with `#`, the message is sent to a channel. The server fans - out to all channel members (including the sender — the sender sees their own +- If `to` starts with `#`, the message is sent to a channel. The server fans out + to all channel members (including the sender — the sender sees their own message echoed back via the queue). - If `to` is a bare nick, the message is a DM. The server fans out to the recipient and the sender (so all of the sender's clients see the DM). @@ -622,24 +624,30 @@ Send a message to a channel or user. This is the primary messaging command. - If the DM target nick doesn't exist, the server returns HTTP 404. **Response:** `201 Created` + ```json -{"id": "uuid-string", "status": "sent"} +{ "id": "uuid-string", "status": "sent" } ``` **IRC reference:** RFC 1459 §4.4.1 #### NOTICE — Send Notice -Identical to PRIVMSG but **must not trigger auto-replies** from bots or -clients. This prevents infinite loops between automated systems. +Identical to PRIVMSG but **must not trigger auto-replies** from bots or clients. +This prevents infinite loops between automated systems. **C2S:** + ```json -{"command": "NOTICE", "to": "#general", "body": ["server maintenance in 5 min"]} +{ + "command": "NOTICE", + "to": "#general", + "body": ["server maintenance in 5 min"] +} ``` -**Behavior:** Same as PRIVMSG in all respects, except clients receiving a -NOTICE must not send an automatic reply. +**Behavior:** Same as PRIVMSG in all respects, except clients receiving a NOTICE +must not send an automatic reply. **IRC reference:** RFC 1459 §4.4.2 @@ -648,6 +656,7 @@ NOTICE must not send an automatic reply. Join a channel. If the channel doesn't exist, it is created. **C2S:** + ```json {"command": "JOIN", "to": "#general"} {"command": "JOIN", "to": "general"} @@ -656,15 +665,16 @@ Join a channel. If the channel doesn't exist, it is created. If the `#` prefix is omitted, the server adds it. **S2C (broadcast to all channel members, including the joiner):** + ```json { - "id": "...", - "command": "JOIN", - "from": "alice", - "to": "#general", - "body": [], - "ts": "2026-02-10T20:00:00.000000000Z", - "meta": {} + "id": "...", + "command": "JOIN", + "from": "alice", + "to": "#general", + "body": [], + "ts": "2026-02-10T20:00:00.000000000Z", + "meta": {} } ``` @@ -673,15 +683,16 @@ If the `#` prefix is omitted, the server adds it. - If the channel doesn't exist, it is created with no topic and no modes. - If the user is already in the channel, the JOIN is a no-op (no error, no duplicate broadcast). -- The JOIN event is broadcast to **all** channel members, including the user - who joined. This lets the client confirm the join succeeded and lets other - members update their member lists. +- The JOIN event is broadcast to **all** channel members, including the user who + joined. This lets the client confirm the join succeeded and lets other members + update their member lists. - The first user to join a channel becomes its implicit operator (not yet enforced in current implementation). **Response:** `200 OK` + ```json -{"status": "joined", "channel": "#general"} +{ "status": "joined", "channel": "#general" } ``` **IRC reference:** RFC 1459 §4.2.1 @@ -691,36 +702,39 @@ If the `#` prefix is omitted, the server adds it. Leave a channel. **C2S:** + ```json {"command": "PART", "to": "#general"} {"command": "PART", "to": "#general", "body": ["goodbye"]} ``` **S2C (broadcast to all channel members, including the leaver):** + ```json { - "id": "...", - "command": "PART", - "from": "alice", - "to": "#general", - "body": ["goodbye"], - "ts": "...", - "meta": {} + "id": "...", + "command": "PART", + "from": "alice", + "to": "#general", + "body": ["goodbye"], + "ts": "...", + "meta": {} } ``` **Behavior:** -- The PART event is broadcast **before** the member is removed, so the - departing user receives their own PART event. +- The PART event is broadcast **before** the member is removed, so the departing + user receives their own PART event. - If the channel is empty after the user leaves, the channel is **deleted** (ephemeral channels). - If the user is not in the channel, the server returns an error. - The `body` field is optional and contains a part message (reason). **Response:** `200 OK` + ```json -{"status": "parted", "channel": "#general"} +{ "status": "parted", "channel": "#general" } ``` **IRC reference:** RFC 1459 §4.2.2 @@ -730,20 +744,22 @@ Leave a channel. Change the user's nickname. **C2S:** + ```json -{"command": "NICK", "body": ["newnick"]} +{ "command": "NICK", "body": ["newnick"] } ``` **S2C (broadcast to all users sharing a channel with the changer):** + ```json { - "id": "...", - "command": "NICK", - "from": "oldnick", - "to": "", - "body": ["newnick"], - "ts": "...", - "meta": {} + "id": "...", + "command": "NICK", + "from": "oldnick", + "to": "", + "body": ["newnick"], + "ts": "...", + "meta": {} } ``` @@ -752,19 +768,21 @@ Change the user's nickname. - `body[0]` is the new nick. Must be 1–32 characters. - The `from` field in the broadcast contains the **old** nick. - The `body[0]` in the broadcast contains the **new** nick. -- The NICK event is broadcast to the user themselves and to all users who - share at least one channel with the changer. Each recipient receives the - event exactly once, even if they share multiple channels. +- The NICK event is broadcast to the user themselves and to all users who share + at least one channel with the changer. Each recipient receives the event + exactly once, even if they share multiple channels. - If the new nick is already taken, the server returns HTTP 409 Conflict. **Response:** `200 OK` + ```json -{"status": "ok", "nick": "newnick"} +{ "status": "ok", "nick": "newnick" } ``` **Error (nick taken):** `409 Conflict` + ```json -{"error": "nick already in use"} +{ "error": "nick already in use" } ``` **IRC reference:** RFC 1459 §4.1.2 @@ -772,51 +790,54 @@ Change the user's nickname. #### PASS — Set Session Password Set a password on the current session, enabling multi-client login via -`POST /api/v1/login`. The password is hashed with bcrypt and stored -server-side. +`POST /api/v1/login`. The password is hashed with bcrypt and stored server-side. **C2S:** + ```json -{"command": "PASS", "body": ["mypassword"]} +{ "command": "PASS", "body": ["mypassword"] } ``` **Behavior:** - `body[0]` is the password. Must be at least 8 characters. - On success, the server responds with `{"status": "ok"}`. -- If the password is too short or missing, the server sends - ERR_NEEDMOREPARAMS (461) via the message queue. +- If the password is too short or missing, the server sends ERR_NEEDMOREPARAMS + (461) via the message queue. - Calling PASS again overwrites the previous password. -- Once a password is set, `POST /api/v1/login` can be used with the nick - and password to create additional clients on the same session. +- Once a password is set, `POST /api/v1/login` can be used with the nick and + password to create additional clients on the same session. **Response:** `200 OK` + ```json -{"status": "ok"} +{ "status": "ok" } ``` -**IRC reference:** Inspired by RFC 1459 §4.1.1 (PASS), repurposed for -session password management. +**IRC reference:** Inspired by RFC 1459 §4.1.1 (PASS), repurposed for session +password management. #### TOPIC — Set Channel Topic Set or change a channel's topic. **C2S:** + ```json -{"command": "TOPIC", "to": "#general", "body": ["Welcome to #general"]} +{ "command": "TOPIC", "to": "#general", "body": ["Welcome to #general"] } ``` **S2C (broadcast to all channel members):** + ```json { - "id": "...", - "command": "TOPIC", - "from": "alice", - "to": "#general", - "body": ["Welcome to #general"], - "ts": "...", - "meta": {} + "id": "...", + "command": "TOPIC", + "from": "alice", + "to": "#general", + "body": ["Welcome to #general"], + "ts": "...", + "meta": {} } ``` @@ -825,13 +846,14 @@ Set or change a channel's topic. - Updates the channel's topic in the database. - The TOPIC event is broadcast to all channel members. - If the channel doesn't exist, the server returns an error. -- If the channel has mode `+t` (topic lock, default: ON for new channels), - only operators (`+o`) can change the topic. Non-operators receive +- If the channel has mode `+t` (topic lock, default: ON for new channels), only + operators (`+o`) can change the topic. Non-operators receive `ERR_CHANOPRIVSNEEDED` (482). **Response:** `200 OK` + ```json -{"status": "ok", "topic": "Welcome to #general"} +{ "status": "ok", "topic": "Welcome to #general" } ``` **IRC reference:** RFC 1459 §4.2.4 @@ -841,37 +863,40 @@ Set or change a channel's topic. Destroy the session and disconnect from the server. **C2S:** + ```json {"command": "QUIT"} {"command": "QUIT", "body": ["leaving"]} ``` **S2C (broadcast to all users sharing channels with the quitter):** + ```json { - "id": "...", - "command": "QUIT", - "from": "alice", - "to": "", - "body": ["leaving"], - "ts": "...", - "meta": {} + "id": "...", + "command": "QUIT", + "from": "alice", + "to": "", + "body": ["leaving"], + "ts": "...", + "meta": {} } ``` **Behavior:** -- The QUIT event is broadcast to all users who share a channel with the - quitting user. The quitting user does **not** receive their own QUIT. +- The QUIT event is broadcast to all users who share a channel with the quitting + user. The quitting user does **not** receive their own QUIT. - The user is removed from all channels. - Empty channels are deleted (ephemeral). -- The user's session is destroyed — the auth cookie is invalidated, the nick - is released. +- The user's session is destroyed — the auth cookie is invalidated, the nick is + released. - Subsequent requests with the old auth cookie return HTTP 401. **Response:** `200 OK` + ```json -{"status": "quit"} +{ "status": "quit" } ``` **IRC reference:** RFC 1459 §4.1.6 @@ -881,27 +906,30 @@ Destroy the session and disconnect from the server. Client keepalive. Server responds synchronously with PONG. **C2S:** + ```json -{"command": "PING"} +{ "command": "PING" } ``` **Response (synchronous, not via the queue):** `200 OK` + ```json -{"command": "PONG", "from": "servername"} +{ "command": "PONG", "from": "servername" } ``` -**Note:** PING/PONG is synchronous — the PONG is the HTTP response body, not -a queued message. This is deliberate: keepalives should be low-latency and -not pollute the message queue. +**Note:** PING/PONG is synchronous — the PONG is the HTTP response body, not a +queued message. This is deliberate: keepalives should be low-latency and not +pollute the message queue. **IRC reference:** RFC 1459 §4.6.2, §4.6.3 #### MODE — Query Modes -Query channel or user modes. Returns the current mode string and, for -channels, the creation timestamp. +Query channel or user modes. Returns the current mode string and, for channels, +the creation timestamp. **C2S:** + ```json {"command": "MODE", "to": "#general"} {"command": "MODE", "to": "alice"} @@ -909,16 +937,18 @@ channels, the creation timestamp. **S2C (via message queue):** -For channels, the server sends RPL_CHANNELMODEIS (324) and -RPL_CREATIONTIME (329): +For channels, the server sends RPL_CHANNELMODEIS (324) and RPL_CREATIONTIME +(329): + ```json {"command": "324", "to": "alice", "params": ["#general", "+n"]} {"command": "329", "to": "alice", "params": ["#general", "1709251200"]} ``` For users, the server sends RPL_UMODEIS (221): + ```json -{"command": "221", "to": "alice", "body": ["+"]} +{ "command": "221", "to": "alice", "body": ["+"] } ``` **Note:** Mode changes (setting/unsetting modes) are not yet implemented. @@ -932,49 +962,53 @@ Request the member list for a channel. Returns RPL_NAMREPLY (353) and RPL_ENDOFNAMES (366). **C2S:** + ```json -{"command": "NAMES", "to": "#general"} +{ "command": "NAMES", "to": "#general" } ``` **IRC reference:** RFC 1459 §4.2.5 #### LIST — List Channels -Request a list of all channels with member counts. Returns RPL_LIST (322) -for each channel followed by RPL_LISTEND (323). +Request a list of all channels with member counts. Returns RPL_LIST (322) for +each channel followed by RPL_LISTEND (323). **C2S:** + ```json -{"command": "LIST"} +{ "command": "LIST" } ``` **IRC reference:** RFC 1459 §4.2.6 #### WHOIS — User Information -Query information about a user. Returns RPL_WHOISUSER (311), -RPL_WHOISSERVER (312), RPL_WHOISOPERATOR (313, if target is oper), -RPL_WHOISIDLE (317), RPL_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318). +Query information about a user. Returns RPL_WHOISUSER (311), RPL_WHOISSERVER +(312), RPL_WHOISOPERATOR (313, if target is oper), RPL_WHOISIDLE (317), +RPL_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318). -If the querying user is a **server operator** (authenticated via `OPER`), -the response additionally includes RPL_WHOISACTUALLY (338) with the -target's current client IP address and hostname. +If the querying user is a **server operator** (authenticated via `OPER`), the +response additionally includes RPL_WHOISACTUALLY (338) with the target's current +client IP address and hostname. **C2S:** + ```json -{"command": "WHOIS", "to": "alice"} +{ "command": "WHOIS", "to": "alice" } ``` **IRC reference:** RFC 1459 §4.5.2 #### WHO — Channel User List -Query users in a channel. Returns RPL_WHOREPLY (352) for each user followed -by RPL_ENDOFWHO (315). +Query users in a channel. Returns RPL_WHOREPLY (352) for each user followed by +RPL_ENDOFWHO (315). **C2S:** + ```json -{"command": "WHO", "to": "#general"} +{ "command": "WHO", "to": "#general" } ``` **IRC reference:** RFC 1459 §4.5.1 @@ -985,8 +1019,9 @@ Request server user/channel statistics. Returns RPL_LUSERCLIENT (251), RPL_LUSEROP (252), RPL_LUSERCHANNELS (254), and RPL_LUSERME (255). **C2S:** + ```json -{"command": "LUSERS"} +{ "command": "LUSERS" } ``` LUSERS replies are also sent automatically during connection registration. @@ -995,18 +1030,20 @@ LUSERS replies are also sent automatically during connection registration. #### OPER — Gain Server Operator Status -Authenticate as a server operator (o-line). On success, the session gains -oper privileges, which currently means additional information is visible in -WHOIS responses (e.g., target user's current client IP and hostname). +Authenticate as a server operator (o-line). On success, the session gains oper +privileges, which currently means additional information is visible in WHOIS +responses (e.g., target user's current client IP and hostname). **C2S:** + ```json -{"command": "OPER", "body": ["opername", "operpassword"]} +{ "command": "OPER", "body": ["opername", "operpassword"] } ``` **S2C (via message queue on success):** + ```json -{"command": "381", "to": "alice", "body": ["You are now an IRC operator"]} +{ "command": "381", "to": "alice", "body": ["You are now an IRC operator"] } ``` **Behavior:** @@ -1014,8 +1051,8 @@ WHOIS responses (e.g., target user's current client IP and hostname). - `body[0]` is the operator name, `body[1]` is the operator password. - The server checks against the configured `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD` environment variables. -- On success, the session's `is_oper` flag is set and `381 RPL_YOUREOPER` - is returned. +- On success, the session's `is_oper` flag is set and `381 RPL_YOUREOPER` is + returned. - On failure (wrong credentials or no o-line configured), `491 ERR_NOOPERHOST` is returned. - Oper status persists for the session lifetime. There is no de-oper command. @@ -1028,14 +1065,16 @@ Remove a user from a channel. Only channel operators (`+o`) can use this command. The kicked user and all channel members receive the KICK message. **C2S:** + ```json -{"command": "KICK", "to": "#general", "body": ["bob", "misbehaving"]} +{ "command": "KICK", "to": "#general", "body": ["bob", "misbehaving"] } ``` The first element of `body` is the target nick, the second (optional) is the reason. If no reason is provided, the kicker's nick is used as the default. **Errors:** + - `482` (ERR_CHANOPRIVSNEEDED) — kicker is not a channel operator - `441` (ERR_USERNOTINCHANNEL) — target is not in the channel - `403` (ERR_NOSUCHCHANNEL) — channel does not exist @@ -1047,25 +1086,30 @@ reason. If no reason is provided, the kicker's nick is used as the default. Distribute a public signing key to channel members. **C2S:** + ```json -{"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64-encoded-pubkey"}} +{ + "command": "PUBKEY", + "body": { "alg": "ed25519", "key": "base64-encoded-pubkey" } +} ``` **S2C (relayed to channel members):** + ```json { - "id": "...", - "command": "PUBKEY", - "from": "alice", - "body": {"alg": "ed25519", "key": "base64-encoded-pubkey"}, - "ts": "...", - "meta": {} + "id": "...", + "command": "PUBKEY", + "from": "alice", + "body": { "alg": "ed25519", "key": "base64-encoded-pubkey" }, + "ts": "...", + "meta": {} } ``` **Behavior:** The server relays PUBKEY messages verbatim. It does not verify, -store, or interpret the key material. See [Security Model](#security-model) -for the full key distribution protocol. +store, or interpret the key material. See [Security Model](#security-model) for +the full key distribution protocol. **Status:** Not yet implemented. @@ -1075,47 +1119,47 @@ Numeric replies follow IRC conventions from RFC 1459/2812. They are sent from the server to the client (never C2S) and use 3-digit string codes in the `command` field. -| Code | Name | When Sent | Example | -|------|----------------------|-----------|---------| -| `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` | -| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is neoirc, running version 0.1"]}` | -| `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` | -| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc","0.1","","ikmnostl"]}` | -| `005` | RPL_ISUPPORT | After session creation | `{"command":"005","to":"alice","params":["CHANTYPES=#","NICKLEN=32","PREFIX=(ov)@+","CHANMODES=b,k,Hl,imnst","NETWORK=neoirc"],"body":["are supported by this server"]}` | -| `221` | RPL_UMODEIS | In response to user MODE query | `{"command":"221","to":"alice","body":["+"]}` | -| `251` | RPL_LUSERCLIENT | On connect or LUSERS command | `{"command":"251","to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` | -| `252` | RPL_LUSEROP | On connect or LUSERS command | `{"command":"252","to":"alice","params":["0"],"body":["operator(s) online"]}` | -| `254` | RPL_LUSERCHANNELS | On connect or LUSERS command | `{"command":"254","to":"alice","params":["3"],"body":["channels formed"]}` | -| `255` | RPL_LUSERME | On connect or LUSERS command | `{"command":"255","to":"alice","body":["I have 5 clients and 1 servers"]}` | -| `311` | RPL_WHOISUSER | In response to WHOIS | `{"command":"311","to":"alice","params":["bob","bobident","host.example.com","*"],"body":["bob"]}` | -| `312` | RPL_WHOISSERVER | In response to WHOIS | `{"command":"312","to":"alice","params":["bob","neoirc"],"body":["neoirc server"]}` | -| `313` | RPL_WHOISOPERATOR | In WHOIS if target is oper | `{"command":"313","to":"alice","params":["bob"],"body":["is an IRC operator"]}` | -| `315` | RPL_ENDOFWHO | End of WHO response | `{"command":"315","to":"alice","params":["#general"],"body":["End of /WHO list"]}` | -| `318` | RPL_ENDOFWHOIS | End of WHOIS response | `{"command":"318","to":"alice","params":["bob"],"body":["End of /WHOIS list"]}` | -| `319` | RPL_WHOISCHANNELS | In response to WHOIS | `{"command":"319","to":"alice","params":["bob"],"body":["#general #dev"]}` | -| `338` | RPL_WHOISACTUALLY | In WHOIS when querier is oper | `{"command":"338","to":"alice","params":["bob","192.168.1.1"],"body":["is actually using host client.example.com"]}` | -| `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General discussion"]}` | -| `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` | -| `324` | RPL_CHANNELMODEIS | In response to channel MODE query | `{"command":"324","to":"alice","params":["#general","+n"]}` | -| `329` | RPL_CREATIONTIME | After channel MODE query | `{"command":"329","to":"alice","params":["#general","1709251200"]}` | -| `331` | RPL_NOTOPIC | Channel has no topic (on JOIN) | `{"command":"331","to":"alice","params":["#general"],"body":["No topic is set"]}` | -| `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` | -| `352` | RPL_WHOREPLY | In response to WHO | `{"command":"352","to":"alice","params":["#general","bobident","host.example.com","neoirc","bob","H"],"body":["0 bob"]}` | -| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["op1!op1@host1 alice!alice@host2 bob!bob@host3"]}` | -| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` | -| `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` | -| `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"375","to":"alice","body":["- neoirc-server Message of the Day -"]}` | -| `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"376","to":"alice","body":["End of /MOTD command"]}` | -| `381` | RPL_YOUREOPER | Successful OPER auth | `{"command":"381","to":"alice","body":["You are now an IRC operator"]}` | -| `401` | ERR_NOSUCHNICK | DM to nonexistent nick | `{"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}` | -| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` | -| `421` | ERR_UNKNOWNCOMMAND | Unrecognized command | `{"command":"421","to":"alice","params":["FOO"],"body":["Unknown command"]}` | -| `432` | ERR_ERRONEUSNICKNAME | Invalid nick format | `{"command":"432","to":"alice","params":["bad nick!"],"body":["Erroneous nickname"]}` | -| `433` | ERR_NICKNAMEINUSE | NICK to taken nick | `{"command":"433","to":"*","params":["alice"],"body":["Nickname is already in use"]}` | -| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` | -| `461` | ERR_NEEDMOREPARAMS | Missing required fields | `{"command":"461","to":"alice","params":["JOIN"],"body":["Not enough parameters"]}` | -| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` | -| `491` | ERR_NOOPERHOST | Failed OPER auth | `{"command":"491","to":"alice","body":["No O-lines for your host"]}` | +| Code | Name | When Sent | Example | +| ----- | -------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` | +| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is neoirc, running version 0.1"]}` | +| `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` | +| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc","0.1","","ikmnostl"]}` | +| `005` | RPL_ISUPPORT | After session creation | `{"command":"005","to":"alice","params":["CHANTYPES=#","NICKLEN=32","PREFIX=(ov)@+","CHANMODES=b,k,Hl,imnst","NETWORK=neoirc"],"body":["are supported by this server"]}` | +| `221` | RPL_UMODEIS | In response to user MODE query | `{"command":"221","to":"alice","body":["+"]}` | +| `251` | RPL_LUSERCLIENT | On connect or LUSERS command | `{"command":"251","to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` | +| `252` | RPL_LUSEROP | On connect or LUSERS command | `{"command":"252","to":"alice","params":["0"],"body":["operator(s) online"]}` | +| `254` | RPL_LUSERCHANNELS | On connect or LUSERS command | `{"command":"254","to":"alice","params":["3"],"body":["channels formed"]}` | +| `255` | RPL_LUSERME | On connect or LUSERS command | `{"command":"255","to":"alice","body":["I have 5 clients and 1 servers"]}` | +| `311` | RPL_WHOISUSER | In response to WHOIS | `{"command":"311","to":"alice","params":["bob","bobident","host.example.com","*"],"body":["bob"]}` | +| `312` | RPL_WHOISSERVER | In response to WHOIS | `{"command":"312","to":"alice","params":["bob","neoirc"],"body":["neoirc server"]}` | +| `313` | RPL_WHOISOPERATOR | In WHOIS if target is oper | `{"command":"313","to":"alice","params":["bob"],"body":["is an IRC operator"]}` | +| `315` | RPL_ENDOFWHO | End of WHO response | `{"command":"315","to":"alice","params":["#general"],"body":["End of /WHO list"]}` | +| `318` | RPL_ENDOFWHOIS | End of WHOIS response | `{"command":"318","to":"alice","params":["bob"],"body":["End of /WHOIS list"]}` | +| `319` | RPL_WHOISCHANNELS | In response to WHOIS | `{"command":"319","to":"alice","params":["bob"],"body":["#general #dev"]}` | +| `338` | RPL_WHOISACTUALLY | In WHOIS when querier is oper | `{"command":"338","to":"alice","params":["bob","192.168.1.1"],"body":["is actually using host client.example.com"]}` | +| `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General discussion"]}` | +| `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` | +| `324` | RPL_CHANNELMODEIS | In response to channel MODE query | `{"command":"324","to":"alice","params":["#general","+n"]}` | +| `329` | RPL_CREATIONTIME | After channel MODE query | `{"command":"329","to":"alice","params":["#general","1709251200"]}` | +| `331` | RPL_NOTOPIC | Channel has no topic (on JOIN) | `{"command":"331","to":"alice","params":["#general"],"body":["No topic is set"]}` | +| `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` | +| `352` | RPL_WHOREPLY | In response to WHO | `{"command":"352","to":"alice","params":["#general","bobident","host.example.com","neoirc","bob","H"],"body":["0 bob"]}` | +| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["op1!op1@host1 alice!alice@host2 bob!bob@host3"]}` | +| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` | +| `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` | +| `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"375","to":"alice","body":["- neoirc-server Message of the Day -"]}` | +| `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"376","to":"alice","body":["End of /MOTD command"]}` | +| `381` | RPL_YOUREOPER | Successful OPER auth | `{"command":"381","to":"alice","body":["You are now an IRC operator"]}` | +| `401` | ERR_NOSUCHNICK | DM to nonexistent nick | `{"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}` | +| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` | +| `421` | ERR_UNKNOWNCOMMAND | Unrecognized command | `{"command":"421","to":"alice","params":["FOO"],"body":["Unknown command"]}` | +| `432` | ERR_ERRONEUSNICKNAME | Invalid nick format | `{"command":"432","to":"alice","params":["bad nick!"],"body":["Erroneous nickname"]}` | +| `433` | ERR_NICKNAMEINUSE | NICK to taken nick | `{"command":"433","to":"*","params":["alice"],"body":["Nickname is already in use"]}` | +| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` | +| `461` | ERR_NEEDMOREPARAMS | Missing required fields | `{"command":"461","to":"alice","params":["JOIN"],"body":["Not enough parameters"]}` | +| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` | +| `491` | ERR_NOOPERHOST | Failed OPER auth | `{"command":"491","to":"alice","body":["No O-lines for your host"]}` | **Note:** Numeric replies are now implemented. All IRC command responses (success and error) are delivered as numeric replies through the message queue. @@ -1127,22 +1171,22 @@ carries IRC-style parameters (e.g., channel name, target nick). Inspired by IRC, simplified: -| Mode | Name | Meaning | Status | -|------|----------------|---------|--------| -| `+b` | Ban | Prevents matching hostmasks from joining or sending (parameter: `nick!user@host` mask with wildcards) | **Enforced** | -| `+i` | Invite-only | Only invited users can join; use `INVITE nick #channel` to invite | **Enforced** | -| `+k` | Channel key | Requires a password to join (parameter: key string) | **Enforced** | -| `+l` | User limit | Maximum number of members allowed in the channel (parameter: integer) | **Enforced** | -| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** | -| `+n` | No external | Only channel members can send messages to the channel | **Enforced** | -| `+s` | Secret | Channel hidden from LIST and WHOIS for non-members | **Enforced** | -| `+t` | Topic lock | Only operators can change the topic (default: ON) | **Enforced** | -| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | **Enforced** | +| Mode | Name | Meaning | Status | +| ---- | ----------- | ----------------------------------------------------------------------------------------------------- | ------------ | +| `+b` | Ban | Prevents matching hostmasks from joining or sending (parameter: `nick!user@host` mask with wildcards) | **Enforced** | +| `+i` | Invite-only | Only invited users can join; use `INVITE nick #channel` to invite | **Enforced** | +| `+k` | Channel key | Requires a password to join (parameter: key string) | **Enforced** | +| `+l` | User limit | Maximum number of members allowed in the channel (parameter: integer) | **Enforced** | +| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** | +| `+n` | No external | Only channel members can send messages to the channel | **Enforced** | +| `+s` | Secret | Channel hidden from LIST and WHOIS for non-members | **Enforced** | +| `+t` | Topic lock | Only operators can change the topic (default: ON) | **Enforced** | +| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | **Enforced** | **User channel modes (set per-user per-channel):** -| Mode | Meaning | Display prefix | Status | -|------|---------|----------------|--------| +| Mode | Meaning | Display prefix | Status | +| ---- | -------- | ------------------ | ------------ | | `+o` | Operator | `@` in NAMES reply | **Enforced** | | `+v` | Voice | `+` in NAMES reply | **Enforced** | @@ -1182,18 +1226,19 @@ MODE #channel +l 50 — set limit to 50 members MODE #channel -l — remove the limit ``` -**Secret (+s):** Hides the channel from `LIST` for non-members and from -`WHOIS` channel lists when the querier is not in the same channel. +**Secret (+s):** Hides the channel from `LIST` for non-members and from `WHOIS` +channel lists when the querier is not in the same channel. -**KICK command:** Channel operators can remove users with `KICK #channel nick -[:reason]`. The kicked user and all channel members receive the KICK message. +**KICK command:** Channel operators can remove users with +`KICK #channel nick [:reason]`. The kicked user and all channel members receive +the KICK message. **NOTICE:** Follows RFC 2812 — NOTICE never triggers auto-replies (including RPL_AWAY), and skips hashcash validation on +H channels (servers and services use NOTICE). -**ISUPPORT:** The server advertises `PREFIX=(ov)@+` and -`CHANMODES=b,k,Hl,imnst` in RPL_ISUPPORT (005). +**ISUPPORT:** The server advertises `PREFIX=(ov)@+` and `CHANMODES=b,k,Hl,imnst` +in RPL_ISUPPORT (005). ### Per-Channel Hashcash (Anti-Spam) @@ -1223,12 +1268,12 @@ Include the hashcash stamp in the `meta` field: ```json { - "command": "PRIVMSG", - "to": "#general", - "body": ["hello world"], - "meta": { - "hashcash": "1:20:260317:#general:a1b2c3...bodyhash:1f4a" - } + "command": "PRIVMSG", + "to": "#general", + "body": ["hello world"], + "meta": { + "hashcash": "1:20:260317:#general:a1b2c3...bodyhash:1f4a" + } } ``` @@ -1256,7 +1301,7 @@ All API responses include appropriate HTTP status codes. Error responses have the format: ```json -{"error": "human-readable error message"} +{ "error": "human-readable error message" } ``` ### POST /api/v1/session — Create Session @@ -1269,15 +1314,20 @@ valid stamp in the `pow_token` field of the JSON request body. The required difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field. **Request Body:** + ```json -{"nick": "alice", "username": "alice", "pow_token": "1:20:260310:neoirc::3a2f1"} +{ + "nick": "alice", + "username": "alice", + "pow_token": "1:20:260310:neoirc::3a2f1" +} ``` -| Field | Type | Required | Constraints | -|------------|--------|-------------|-------------| -| `nick` | string | Yes | 1–32 characters, must be unique on the server | -| `username` | string | No | 1–32 characters, IRC ident-style. Defaults to nick if omitted. | -| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) | +| Field | Type | Required | Constraints | +| ----------- | ------ | ----------- | -------------------------------------------------------------- | +| `nick` | string | Yes | 1–32 characters, must be unique on the server | +| `username` | string | No | 1–32 characters, IRC ident-style. Defaults to nick if omitted. | +| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) | The `username` field sets the user portion of the IRC hostmask (`nick!user@host`). The hostname is automatically resolved via reverse DNS of @@ -1295,37 +1345,38 @@ Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict ```json { - "id": 1, - "nick": "alice" + "id": 1, + "nick": "alice" } ``` -| Field | Type | Description | -|---------|---------|-------------| -| `id` | integer | Server-assigned user ID | -| `nick` | string | Confirmed nick (always matches request on success) | +| Field | Type | Description | +| ------ | ------- | -------------------------------------------------- | +| `id` | integer | Server-assigned user ID | +| `nick` | string | Confirmed nick (always matches request on success) | **Cookie properties:** -| Property | Value | -|------------|-------| -| `Name` | `neoirc_auth` | +| Property | Value | +| ---------- | --------------------------------------- | +| `Name` | `neoirc_auth` | | `HttpOnly` | `true` (not accessible from JavaScript) | -| `SameSite` | `Strict` (prevents CSRF) | -| `Secure` | `true` when behind TLS | -| `Path` | `/` | +| `SameSite` | `Strict` (prevents CSRF) | +| `Secure` | `true` (sent only over HTTPS) | +| `Path` | `/` | **Errors:** -| Status | Error | When | -|--------|-------|------| -| 400 | `nick must be 1-32 characters` | Empty or too-long nick | -| 400 | `invalid username format` | Username doesn't match allowed format | +| Status | Error | When | +| ------ | --------------------------------- | ------------------------------------------------------------------ | +| 400 | `nick must be 1-32 characters` | Empty or too-long nick | +| 400 | `invalid username format` | Username doesn't match allowed format | | 402 | `hashcash proof-of-work required` | Missing `pow_token` field in request body when hashcash is enabled | -| 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) | -| 409 | `nick already taken` | Another active session holds this nick | +| 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) | +| 409 | `nick already taken` | Another active session holds this nick | **curl example:** + ```bash # Use -c to save cookies, -b to send them curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \ @@ -1345,14 +1396,15 @@ state (JOIN + TOPIC + NAMES for each channel the session belongs to) into the new client's queue, so the client can immediately restore its UI state. **Request Body:** + ```json -{"nick": "alice", "password": "mypassword"} +{ "nick": "alice", "password": "mypassword" } ``` -| Field | Type | Required | Constraints | -|------------|--------|----------|-------------| +| Field | Type | Required | Constraints | +| ---------- | ------ | -------- | ------------------------------------------------ | | `nick` | string | Yes | Must match an active session with a password set | -| `password` | string | Yes | Must match the session's password | +| `password` | string | Yes | Must match the session's password | **Response:** `200 OK` @@ -1360,24 +1412,25 @@ The response sets an `neoirc_auth` HttpOnly cookie for the new client. ```json { - "id": 1, - "nick": "alice" + "id": 1, + "nick": "alice" } ``` -| Field | Type | Description | -|---------|---------|-------------| -| `id` | integer | Session ID | -| `nick` | string | Current nick | +| Field | Type | Description | +| ------ | ------- | ------------ | +| `id` | integer | Session ID | +| `nick` | string | Current nick | **Errors:** -| Status | Error | When | -|--------|-------|------| -| 400 | `nick and password required` | Missing nick or password | -| 401 | `invalid credentials` | Wrong password, nick not found, or session has no password set | +| Status | Error | When | +| ------ | ---------------------------- | -------------------------------------------------------------- | +| 400 | `nick and password required` | Missing nick or password | +| 401 | `invalid credentials` | Wrong password, nick not found, or session has no password set | **curl example:** + ```bash curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/login \ -H 'Content-Type: application/json' \ @@ -1392,43 +1445,46 @@ Return the current user's session state. **Query Parameters:** -| Parameter | Type | Default | Description | -|-----------|--------|---------|-------------| -| `initChannelState` | string | (none) | When set to `1`, enqueues synthetic JOIN + TOPIC + NAMES messages for every channel the session belongs to into the calling client's queue. Used by the SPA on reconnect to restore channel tabs without re-sending JOIN commands. | +| Parameter | Type | Default | Description | +| ------------------ | ------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `initChannelState` | string | (none) | When set to `1`, enqueues synthetic JOIN + TOPIC + NAMES messages for every channel the session belongs to into the calling client's queue. Used by the SPA on reconnect to restore channel tabs without re-sending JOIN commands. | **Response:** `200 OK` + ```json { - "id": 1, - "nick": "alice", - "channels": [ - {"id": 1, "name": "#general", "topic": "Welcome!"}, - {"id": 2, "name": "#dev", "topic": ""} - ] + "id": 1, + "nick": "alice", + "channels": [ + { "id": 1, "name": "#general", "topic": "Welcome!" }, + { "id": 2, "name": "#dev", "topic": "" } + ] } ``` -| Field | Type | Description | -|------------|--------|-------------| -| `id` | integer | User ID | -| `nick` | string | Current nick | +| Field | Type | Description | +| ---------- | ------- | -------------------------------- | +| `id` | integer | User ID | +| `nick` | string | Current nick | | `channels` | array | Channels the user is a member of | Each channel object: -| Field | Type | Description | -|---------|---------|-------------| -| `id` | integer | Channel ID | -| `name` | string | Channel name (e.g., `#general`) | +| Field | Type | Description | +| ------- | ------- | ------------------------------------- | +| `id` | integer | Channel ID | +| `name` | string | Channel name (e.g., `#general`) | | `topic` | string | Channel topic (empty string if unset) | **curl example:** + ```bash curl -s http://localhost:8080/api/v1/state \ -b cookies.txt | jq . ``` **Reconnect with channel state initialization:** + ```bash curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \ -b cookies.txt | jq . @@ -1441,60 +1497,63 @@ real-time endpoint — clients call it in a loop. **Query Parameters:** -| Param | Type | Default | Description | -|-----------|---------|---------|-------------| +| Param | Type | Default | Description | +| --------- | ------- | ------- | ----------------------------------------------------------------------------------------- | | `after` | integer | `0` | Return only queue entries with ID > this value. Use `last_id` from the previous response. | -| `timeout` | integer | `0` | Long-poll timeout in seconds. `0` = return immediately. Max `30`. Recommended: `15`. | +| `timeout` | integer | `0` | Long-poll timeout in seconds. `0` = return immediately. Max `30`. Recommended: `15`. | **Response:** `200 OK` + ```json { - "messages": [ - { - "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5", - "command": "JOIN", - "from": "bob", - "to": "#general", - "body": [], - "ts": "2026-02-10T20:00:00.000000000Z", - "meta": {} - }, - { - "id": "b7c8210f-849c-4b90-9ee8-d99c8889358e", - "command": "PRIVMSG", - "from": "alice", - "to": "#general", - "body": ["hello world"], - "ts": "2026-02-10T20:00:01.000000000Z", - "meta": {} - } - ], - "last_id": 42 + "messages": [ + { + "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5", + "command": "JOIN", + "from": "bob", + "to": "#general", + "body": [], + "ts": "2026-02-10T20:00:00.000000000Z", + "meta": {} + }, + { + "id": "b7c8210f-849c-4b90-9ee8-d99c8889358e", + "command": "PRIVMSG", + "from": "alice", + "to": "#general", + "body": ["hello world"], + "ts": "2026-02-10T20:00:01.000000000Z", + "meta": {} + } + ], + "last_id": 42 } ``` -| Field | Type | Description | -|------------|---------|-------------| +| Field | Type | Description | +| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------- | | `messages` | array | Array of IRC message envelopes (see [Protocol Specification](#protocol-specification)). Empty array if no messages. | -| `last_id` | integer | Queue cursor. Pass this as `after` in the next request. | +| `last_id` | integer | Queue cursor. Pass this as `after` in the next request. | **Long-poll behavior:** -1. If messages are immediately available (queue entries with ID > `after`), - the server responds instantly. +1. If messages are immediately available (queue entries with ID > `after`), the + server responds instantly. 2. If no messages are available and `timeout` > 0, the server holds the connection open. 3. The server responds when: - - A message arrives for this user (instantly via in-memory broker) - - The timeout expires (returns `{"messages":[], "last_id": }`) - - The client disconnects (no response) + - A message arrives for this user (instantly via in-memory broker) + - The timeout expires (returns `{"messages":[], "last_id": }`) + - The client disconnects (no response) **curl example (immediate):** + ```bash curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq . ``` **curl example (long-poll, 15s):** + ```bash curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=42&timeout=15" | jq . ``` @@ -1508,7 +1567,7 @@ part, nick, etc. **Request body:** An IRC message envelope with `command` and relevant fields: ```json -{"command": "PRIVMSG", "to": "#general", "body": ["hello world"]} +{ "command": "PRIVMSG", "to": "#general", "body": ["hello world"] } ``` See [Commands (C2S and S2C)](#commands-c2s-and-s2c) for the full command @@ -1516,121 +1575,122 @@ reference with all required and optional fields. **Command dispatch table:** -| Command | Required Fields | Optional | Response Status | -|-----------|---------------------|---------------|-----------------| -| `PRIVMSG` | `to`, `body` | `meta` | 200 OK | -| `NOTICE` | `to`, `body` | `meta` | 200 OK | -| `JOIN` | `to` | | 200 OK | -| `PART` | `to` | `body` | 200 OK | -| `NICK` | `body` | | 200 OK | -| `PASS` | `body` | | 200 OK | -| `TOPIC` | `to`, `body` | | 200 OK | -| `MODE` | `to` | | 200 OK | -| `NAMES` | `to` | | 200 OK | -| `LIST` | | | 200 OK | -| `WHOIS` | `to` or `body` | | 200 OK | -| `WHO` | `to` | | 200 OK | -| `LUSERS` | | | 200 OK | -| `OPER` | `body` | | 200 OK | -| `QUIT` | | `body` | 200 OK | -| `PING` | | | 200 OK | +| Command | Required Fields | Optional | Response Status | +| --------- | --------------- | -------- | --------------- | +| `PRIVMSG` | `to`, `body` | `meta` | 200 OK | +| `NOTICE` | `to`, `body` | `meta` | 200 OK | +| `JOIN` | `to` | | 200 OK | +| `PART` | `to` | `body` | 200 OK | +| `NICK` | `body` | | 200 OK | +| `PASS` | `body` | | 200 OK | +| `TOPIC` | `to`, `body` | | 200 OK | +| `MODE` | `to` | | 200 OK | +| `NAMES` | `to` | | 200 OK | +| `LIST` | | | 200 OK | +| `WHOIS` | `to` or `body` | | 200 OK | +| `WHO` | `to` | | 200 OK | +| `LUSERS` | | | 200 OK | +| `OPER` | `body` | | 200 OK | +| `QUIT` | | `body` | 200 OK | +| `PING` | | | 200 OK | -All IRC commands return HTTP 200 OK. IRC-level success and error responses -are delivered as **numeric replies** through the message queue (see +All IRC commands return HTTP 200 OK. IRC-level success and error responses are +delivered as **numeric replies** through the message queue (see [Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are reserved for transport-level problems: malformed JSON (400), missing/invalid auth cookies (401), and server errors (500). **HTTP errors (transport-level only):** -| Status | Error | When | -|--------|-------|------| +| Status | Error | When | +| ------ | ----------------- | ------------------------------- | | 400 | `invalid request` | Malformed JSON or empty command | -| 401 | `unauthorized` | Missing or invalid auth cookie | -| 500 | `internal error` | Server-side failure | +| 401 | `unauthorized` | Missing or invalid auth cookie | +| 500 | `internal error` | Server-side failure | **IRC numeric error replies (delivered via message queue):** -| Numeric | Name | When | -|---------|------|------| -| 401 | ERR_NOSUCHNICK | DM target nick doesn't exist | -| 403 | ERR_NOSUCHCHANNEL | Target channel doesn't exist or invalid name | -| 421 | ERR_UNKNOWNCOMMAND | Unrecognized command | -| 432 | ERR_ERRONEUSNICKNAME | Invalid nickname format | -| 433 | ERR_NICKNAMEINUSE | NICK target is taken | -| 442 | ERR_NOTONCHANNEL | Not a member of the target channel | -| 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) | -| 491 | ERR_NOOPERHOST | Failed OPER authentication | +| Numeric | Name | When | +| ------- | -------------------- | -------------------------------------------- | +| 401 | ERR_NOSUCHNICK | DM target nick doesn't exist | +| 403 | ERR_NOSUCHCHANNEL | Target channel doesn't exist or invalid name | +| 421 | ERR_UNKNOWNCOMMAND | Unrecognized command | +| 432 | ERR_ERRONEUSNICKNAME | Invalid nickname format | +| 433 | ERR_NICKNAMEINUSE | NICK target is taken | +| 442 | ERR_NOTONCHANNEL | Not a member of the target channel | +| 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) | +| 491 | ERR_NOOPERHOST | Failed OPER authentication | **IRC numeric success replies (delivered via message queue):** -| Numeric | Name | When | -|---------|------|------| -| 001 | RPL_WELCOME | Sent on session creation/login | -| 002 | RPL_YOURHOST | Sent on session creation/login | -| 003 | RPL_CREATED | Sent on session creation/login | -| 004 | RPL_MYINFO | Sent on session creation/login | -| 005 | RPL_ISUPPORT | Sent on session creation/login | -| 221 | RPL_UMODEIS | In response to user MODE query | -| 251 | RPL_LUSERCLIENT | On connect or LUSERS command | -| 252 | RPL_LUSEROP | On connect or LUSERS command | -| 254 | RPL_LUSERCHANNELS | On connect or LUSERS command | -| 255 | RPL_LUSERME | On connect or LUSERS command | -| 311 | RPL_WHOISUSER | WHOIS user info | -| 312 | RPL_WHOISSERVER | WHOIS server info | -| 313 | RPL_WHOISOPERATOR | WHOIS target is oper | -| 315 | RPL_ENDOFWHO | End of WHO list | -| 318 | RPL_ENDOFWHOIS | End of WHOIS list | -| 319 | RPL_WHOISCHANNELS | WHOIS channels list | -| 338 | RPL_WHOISACTUALLY | WHOIS client IP (oper-only) | -| 322 | RPL_LIST | Channel in LIST response | -| 323 | RPL_LISTEND | End of LIST | -| 324 | RPL_CHANNELMODEIS | Channel mode query response | -| 329 | RPL_CREATIONTIME | Channel creation timestamp | -| 331 | RPL_NOTOPIC | Channel has no topic (on JOIN) | -| 332 | RPL_TOPIC | Channel topic (on JOIN, TOPIC set) | -| 352 | RPL_WHOREPLY | User in WHO response | -| 353 | RPL_NAMREPLY | Channel member list (on JOIN, NAMES) | -| 366 | RPL_ENDOFNAMES | End of NAMES list | -| 375 | RPL_MOTDSTART | Start of MOTD | -| 372 | RPL_MOTD | MOTD line | -| 376 | RPL_ENDOFMOTD | End of MOTD | -| 381 | RPL_YOUREOPER | Successful OPER authentication | +| Numeric | Name | When | +| ------- | ----------------- | ------------------------------------ | +| 001 | RPL_WELCOME | Sent on session creation/login | +| 002 | RPL_YOURHOST | Sent on session creation/login | +| 003 | RPL_CREATED | Sent on session creation/login | +| 004 | RPL_MYINFO | Sent on session creation/login | +| 005 | RPL_ISUPPORT | Sent on session creation/login | +| 221 | RPL_UMODEIS | In response to user MODE query | +| 251 | RPL_LUSERCLIENT | On connect or LUSERS command | +| 252 | RPL_LUSEROP | On connect or LUSERS command | +| 254 | RPL_LUSERCHANNELS | On connect or LUSERS command | +| 255 | RPL_LUSERME | On connect or LUSERS command | +| 311 | RPL_WHOISUSER | WHOIS user info | +| 312 | RPL_WHOISSERVER | WHOIS server info | +| 313 | RPL_WHOISOPERATOR | WHOIS target is oper | +| 315 | RPL_ENDOFWHO | End of WHO list | +| 318 | RPL_ENDOFWHOIS | End of WHOIS list | +| 319 | RPL_WHOISCHANNELS | WHOIS channels list | +| 338 | RPL_WHOISACTUALLY | WHOIS client IP (oper-only) | +| 322 | RPL_LIST | Channel in LIST response | +| 323 | RPL_LISTEND | End of LIST | +| 324 | RPL_CHANNELMODEIS | Channel mode query response | +| 329 | RPL_CREATIONTIME | Channel creation timestamp | +| 331 | RPL_NOTOPIC | Channel has no topic (on JOIN) | +| 332 | RPL_TOPIC | Channel topic (on JOIN, TOPIC set) | +| 352 | RPL_WHOREPLY | User in WHO response | +| 353 | RPL_NAMREPLY | Channel member list (on JOIN, NAMES) | +| 366 | RPL_ENDOFNAMES | End of NAMES list | +| 375 | RPL_MOTDSTART | Start of MOTD | +| 372 | RPL_MOTD | MOTD line | +| 376 | RPL_ENDOFMOTD | End of MOTD | +| 381 | RPL_YOUREOPER | Successful OPER authentication | ### GET /api/v1/history — Message History -Fetch historical messages for a channel. Returns messages in chronological -order (oldest first). +Fetch historical messages for a channel. Returns messages in chronological order +(oldest first). **Query Parameters:** -| Param | Type | Default | Description | -|----------|---------|---------|-------------| -| `target` | string | (required) | Channel name (e.g., `#general`) | -| `before` | integer | `0` | Return only messages with DB ID < this value (for pagination). `0` means latest. | -| `limit` | integer | `50` | Maximum messages to return. | +| Param | Type | Default | Description | +| -------- | ------- | ---------- | -------------------------------------------------------------------------------- | +| `target` | string | (required) | Channel name (e.g., `#general`) | +| `before` | integer | `0` | Return only messages with DB ID < this value (for pagination). `0` means latest. | +| `limit` | integer | `50` | Maximum messages to return. | **Response:** `200 OK` + ```json [ - { - "id": "uuid-1", - "command": "PRIVMSG", - "from": "alice", - "to": "#general", - "body": ["first message"], - "ts": "2026-02-10T19:00:00.000000000Z", - "meta": {} - }, - { - "id": "uuid-2", - "command": "PRIVMSG", - "from": "bob", - "to": "#general", - "body": ["second message"], - "ts": "2026-02-10T19:01:00.000000000Z", - "meta": {} - } + { + "id": "uuid-1", + "command": "PRIVMSG", + "from": "alice", + "to": "#general", + "body": ["first message"], + "ts": "2026-02-10T19:00:00.000000000Z", + "meta": {} + }, + { + "id": "uuid-2", + "command": "PRIVMSG", + "from": "bob", + "to": "#general", + "body": ["second message"], + "ts": "2026-02-10T19:01:00.000000000Z", + "meta": {} + } ] ``` @@ -1638,6 +1698,7 @@ order (oldest first). events). Event messages are delivered via the live queue only. **curl example:** + ```bash # Latest 50 messages in #general curl -s "http://localhost:8080/api/v1/history?target=%23general&limit=50" \ @@ -1653,10 +1714,11 @@ curl -s "http://localhost:8080/api/v1/history?target=%23general&before=100&limit List all channels on the server. **Response:** `200 OK` + ```json [ - {"id": 1, "name": "#general", "topic": "Welcome!"}, - {"id": 2, "name": "#dev", "topic": "Development discussion"} + { "id": 1, "name": "#general", "topic": "Welcome!" }, + { "id": 2, "name": "#dev", "topic": "Development discussion" } ] ``` @@ -1666,14 +1728,16 @@ List members of a channel. The `{name}` parameter is the channel name **without** the `#` prefix (it's added by the server). **Response:** `200 OK` + ```json [ - {"id": 1, "nick": "alice", "lastSeen": "2026-02-10T20:00:00Z"}, - {"id": 2, "nick": "bob", "lastSeen": "2026-02-10T19:55:00Z"} + { "id": 1, "nick": "alice", "lastSeen": "2026-02-10T20:00:00Z" }, + { "id": 2, "nick": "bob", "lastSeen": "2026-02-10T19:55:00Z" } ] ``` **curl example:** + ```bash curl -s http://localhost:8080/api/v1/channels/general/members \ -b cookies.txt | jq . @@ -1681,10 +1745,10 @@ curl -s http://localhost:8080/api/v1/channels/general/members \ ### POST /api/v1/logout — Logout -Destroy the current client's session cookie and server-side client record. -If no other clients remain on the session, the user is fully cleaned up: -parted from all channels (with QUIT broadcast to members), session deleted, -nick released. The auth cookie is cleared in the response. +Destroy the current client's session cookie and server-side client record. If no +other clients remain on the session, the user is fully cleaned up: parted from +all channels (with QUIT broadcast to members), session deleted, nick released. +The auth cookie is cleared in the response. **Request:** No body. Requires auth cookie. @@ -1693,16 +1757,17 @@ nick released. The auth cookie is cleared in the response. The response clears the `neoirc_auth` cookie. ```json -{"status": "ok"} +{ "status": "ok" } ``` **Errors:** -| Status | Error | When | -|--------|-------|------| +| Status | Error | When | +| ------ | -------------- | ------------------------------ | | 401 | `unauthorized` | Missing or invalid auth cookie | **curl example:** + ```bash curl -s -b cookies.txt -c cookies.txt -X POST http://localhost:8080/api/v1/logout | jq . ``` @@ -1715,17 +1780,17 @@ Return the current user's session state. This is an alias for **Request:** No body. Requires auth. **Response:** `200 OK` + ```json { - "id": 1, - "nick": "alice", - "channels": [ - {"id": 1, "name": "#general", "topic": "Welcome!"} - ] + "id": 1, + "nick": "alice", + "channels": [{ "id": 1, "name": "#general", "topic": "Welcome!" }] } ``` **curl example:** + ```bash curl -s http://localhost:8080/api/v1/users/me \ -b cookies.txt | jq . @@ -1736,22 +1801,23 @@ curl -s http://localhost:8080/api/v1/users/me \ Return server metadata. No authentication required. **Response:** `200 OK` + ```json { - "name": "My NeoIRC Server", - "version": "0.1.0", - "motd": "Welcome! Be nice.", - "users": 42, - "hashcash_bits": 20 + "name": "My NeoIRC Server", + "version": "0.1.0", + "motd": "Welcome! Be nice.", + "users": 42, + "hashcash_bits": 20 } ``` -| Field | Type | Description | -|-----------------|---------|-------------| -| `name` | string | Server display name | -| `version` | string | Server version | -| `motd` | string | Message of the day | -| `users` | integer | Number of currently active user sessions | +| Field | Type | Description | +| --------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `name` | string | Server display name | +| `version` | string | Server version | +| `motd` | string | Message of the day | +| `users` | integer | Number of currently active user sessions | | `hashcash_bits` | integer | Required proof-of-work difficulty (leading zero bits). Only present when > 0. See [Hashcash Proof-of-Work](#hashcash-proof-of-work). | ### GET /.well-known/healthcheck.json — Health Check @@ -1763,31 +1829,31 @@ health status and runtime statistics. ```json { - "status": "ok", - "now": "2024-01-15T12:00:00.000000000Z", - "uptimeSeconds": 3600, - "uptimeHuman": "1h0m0s", - "version": "0.1.0", - "appname": "neoirc", - "maintenanceMode": false, - "sessions": 42, - "clients": 85, - "queuedLines": 128, - "channels": 7, - "connectionsSinceBoot": 200, - "sessionsSinceBoot": 150, - "messagesSinceBoot": 5000 + "status": "ok", + "now": "2024-01-15T12:00:00.000000000Z", + "uptimeSeconds": 3600, + "uptimeHuman": "1h0m0s", + "version": "0.1.0", + "appname": "neoirc", + "maintenanceMode": false, + "sessions": 42, + "clients": 85, + "queuedLines": 128, + "channels": 7, + "connectionsSinceBoot": 200, + "sessionsSinceBoot": 150, + "messagesSinceBoot": 5000 } ``` -| Field | Description | -| ---------------------- | ------------------------------------------------- | -| `sessions` | Current number of active sessions | -| `clients` | Current number of connected clients | -| `queuedLines` | Total entries in client output queues | -| `channels` | Current number of channels | -| `connectionsSinceBoot` | Total client connections since server start | -| `sessionsSinceBoot` | Total sessions created since server start | +| Field | Description | +| ---------------------- | ----------------------------------------------------- | +| `sessions` | Current number of active sessions | +| `clients` | Current number of connected clients | +| `queuedLines` | Total entries in client output queues | +| `channels` | Current number of channels | +| `connectionsSinceBoot` | Total client connections since server start | +| `sessionsSinceBoot` | Total sessions created since server start | | `messagesSinceBoot` | Total PRIVMSG/NOTICE messages sent since server start | --- @@ -1876,69 +1942,76 @@ To produce a deterministic byte representation of a message for signing: 1. Start with the full message envelope (including `id`, `ts`, `from`, etc.) 2. Remove `meta.sig` from the message (the signature itself is not signed) -3. Serialize using [RFC 8785 JSON Canonicalization Scheme (JCS)](https://www.rfc-editor.org/rfc/rfc8785): - - Object keys sorted lexicographically (Unicode code point order) - - No insignificant whitespace - - Numbers serialized in shortest form (no trailing zeros) - - Strings escaped per JSON spec (no unnecessary escapes) - - UTF-8 encoding throughout +3. Serialize using + [RFC 8785 JSON Canonicalization Scheme (JCS)](https://www.rfc-editor.org/rfc/rfc8785): + - Object keys sorted lexicographically (Unicode code point order) + - No insignificant whitespace + - Numbers serialized in shortest form (no trailing zeros) + - Strings escaped per JSON spec (no unnecessary escapes) + - UTF-8 encoding throughout 4. The resulting byte string is the signing input **Example:** Given this message: + ```json { - "command": "PRIVMSG", - "from": "alice", - "to": "#general", - "body": ["hello"], - "id": "abc-123", - "ts": "2026-02-10T20:00:00Z", - "meta": {"alg": "ed25519"} + "command": "PRIVMSG", + "from": "alice", + "to": "#general", + "body": ["hello"], + "id": "abc-123", + "ts": "2026-02-10T20:00:00Z", + "meta": { "alg": "ed25519" } } ``` The JCS canonical form is: + ``` {"body":["hello"],"command":"PRIVMSG","from":"alice","id":"abc-123","meta":{"alg":"ed25519"},"to":"#general","ts":"2026-02-10T20:00:00Z"} ``` This is why `body` must be an object or array — raw strings would be ambiguous -under canonicalization (a bare string `hello` is not valid JSON, and -`"hello"` has different canonical forms depending on escaping rules). +under canonicalization (a bare string `hello` is not valid JSON, and `"hello"` +has different canonical forms depending on escaping rules). ### Signing Flow 1. Client generates an Ed25519 keypair (32-byte seed → 64-byte secret key, 32-byte public key) 2. Client announces public key via PUBKEY command: - ```json - {"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64url-encoded-pubkey"}} - ``` + ```json + { + "command": "PUBKEY", + "body": { "alg": "ed25519", "key": "base64url-encoded-pubkey" } + } + ``` 3. Server relays PUBKEY to channel members and/or stores for the session -4. When sending a message, client: - a. Constructs the complete message envelope **without** `meta.sig` - b. Canonicalizes per JCS (step above) - c. Signs the canonical bytes with the Ed25519 private key - d. Adds `meta.sig` (base64url-encoded signature) and `meta.alg` ("ed25519") +4. When sending a message, client: a. Constructs the complete message envelope + **without** `meta.sig` b. Canonicalizes per JCS (step above) c. Signs the + canonical bytes with the Ed25519 private key d. Adds `meta.sig` + (base64url-encoded signature) and `meta.alg` ("ed25519") 5. Server stores and relays the message including `meta` verbatim -6. Recipients verify by: - a. Extracting and removing `meta.sig` from the received message - b. Canonicalizing the remaining message per JCS - c. Verifying the Ed25519 signature against the sender's announced public key +6. Recipients verify by: a. Extracting and removing `meta.sig` from the received + message b. Canonicalizing the remaining message per JCS c. Verifying the + Ed25519 signature against the sender's announced public key ### PUBKEY Distribution ```json -{"command": "PUBKEY", "from": "alice", - "body": {"alg": "ed25519", "key": "base64url-encoded-32-byte-pubkey"}} +{ + "command": "PUBKEY", + "from": "alice", + "body": { "alg": "ed25519", "key": "base64url-encoded-32-byte-pubkey" } +} ``` - Servers relay PUBKEY messages to all channel members - Clients cache public keys locally, indexed by (server, nick) -- Key distribution uses **TOFU** (trust on first use): the first key seen for - a nick is trusted; subsequent different keys trigger a warning +- Key distribution uses **TOFU** (trust on first use): the first key seen for a + nick is trusted; subsequent different keys trigger a warning - **There is no key revocation mechanism** — if a key is compromised, the user must change their nick or wait for the old key's TOFU cache to expire @@ -1946,16 +2019,16 @@ under canonicalization (a bare string `hello` is not valid JSON, and ```json { - "command": "PRIVMSG", - "from": "alice", - "to": "#general", - "body": ["this message is signed"], - "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5", - "ts": "2026-02-10T20:00:00.000000000Z", - "meta": { - "alg": "ed25519", - "sig": "base64url-encoded-64-byte-signature" - } + "command": "PRIVMSG", + "from": "alice", + "to": "#general", + "body": ["this message is signed"], + "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5", + "ts": "2026-02-10T20:00:00.000000000Z", + "meta": { + "alg": "ed25519", + "sig": "base64url-encoded-64-byte-signature" + } } ``` @@ -1967,24 +2040,23 @@ under canonicalization (a bare string `hello` is not valid JSON, and The server is **trusted for metadata** (it knows who sent what, when, to whom) but **untrusted for message integrity** (signatures let clients verify that -messages haven't been tampered with). This is the same trust model as email -with PGP/DKIM — the mail server sees everything, but signatures prove -authenticity. +messages haven't been tampered with). This is the same trust model as email with +PGP/DKIM — the mail server sees everything, but signatures prove authenticity. ### Authentication - **Cookie-based auth**: Opaque HttpOnly cookies (64 hex chars = 256 bits of entropy). Cookie values are hashed (SHA-256) before storage and validated on every request. Cookies are HttpOnly (no JavaScript access), SameSite=Strict - (CSRF protection), and Secure when behind TLS. + (CSRF protection), and Secure (sent only over HTTPS). - **Anonymous sessions**: `POST /api/v1/session` requires only a nick. No password, instant access. The auth cookie is the sole credential. - **Password-protected sessions**: The PASS IRC command sets a bcrypt-hashed - password on the session. `POST /api/v1/login` authenticates against the - stored hash and issues a new client cookie. + password on the session. `POST /api/v1/login` authenticates against the stored + hash and issues a new client cookie. - **Password security**: Passwords are never stored in plain text. bcrypt - handles salting and key stretching automatically. Sessions without a - password cannot be logged into via `/login`. + handles salting and key stretching automatically. Sessions without a password + cannot be logged into via `/login`. - **Cookie security**: Auth cookies should only be transmitted over HTTPS in production. If a cookie is compromised, the attacker has full access to the session until QUIT or expiry. @@ -2002,36 +2074,41 @@ authenticity. ### Key Management -- **TOFU (Trust On First Use)**: Clients trust the first public key they see - for a nick. This is the same model as SSH host keys. It's simple and works - well when users don't change keys frequently. -- **No key revocation**: Deliberate omission. Key revocation systems are - complex (CRLs, OCSP, key servers) and rarely work well in practice. If your - key is compromised, change your nick. -- **No CA / PKI**: There is no certificate authority. Identity is a key, not - a name bound to a key by a third party. +- **TOFU (Trust On First Use)**: Clients trust the first public key they see for + a nick. This is the same model as SSH host keys. It's simple and works well + when users don't change keys frequently. +- **No key revocation**: Deliberate omission. Key revocation systems are complex + (CRLs, OCSP, key servers) and rarely work well in practice. If your key is + compromised, change your nick. +- **No CA / PKI**: There is no certificate authority. Identity is a key, not a + name bound to a key by a third party. ### DM Privacy -- **DMs are not end-to-end encrypted** in the current implementation. The - server can read DM content. E2E encryption for DMs is planned (see +- **DMs are not end-to-end encrypted** in the current implementation. The server + can read DM content. E2E encryption for DMs is planned (see [Roadmap](#roadmap)). -- **DMs are stored** in the messages table, subject to the same rotation - policy as channel messages. +- **DMs are stored** in the messages table, subject to the same rotation policy + as channel messages. ### Transport Security -- **HTTPS is strongly recommended** for production deployments. The server - itself serves plain HTTP — use a reverse proxy (nginx, Caddy, etc.) for TLS - termination. +- **Clients need HTTPS to keep a session.** The auth cookie is always `Secure`, + and clients send a `Secure` cookie only over HTTPS. The server itself serves + plain HTTP — use a reverse proxy (nginx, Caddy, etc.) for TLS termination. +- **A server on `localhost`**, as in this document's examples, also works over + plain HTTP with curl, `neoirc-cli`, Chrome and Firefox, which treat + `localhost` as secure. Safari does not. Python's `requests` does not either, + so the [Python example](#implementing-long-poll-in-code) sends the cookie + itself. - **CORS**: The server allows all origins with credentials (`Access-Control-Allow-Credentials: true`), reflecting the request Origin. - This enables cookie-based auth from cross-origin clients. Restrict origins - in production via reverse proxy configuration if needed. + This enables cookie-based auth from cross-origin clients. Restrict origins in + production via reverse proxy configuration if needed. - **Content-Security-Policy**: The server sets a strict CSP header on all - responses, restricting resource loading to same-origin and disabling - dangerous features (object embeds, framing, base tag injection). The - embedded SPA works without `'unsafe-inline'` for scripts or styles. + responses, restricting resource loading to same-origin and disabling dangerous + features (object embeds, framing, base tag injection). The embedded SPA works + without `'unsafe-inline'` for scripts or styles. --- @@ -2077,12 +2154,12 @@ POST /api/v1/federation/relay Key properties: -- **Signatures are relayed verbatim** — federated servers do not strip, - modify, or re-sign messages. A signature from a user on server1 can be - verified by a user on server2. +- **Signatures are relayed verbatim** — federated servers do not strip, modify, + or re-sign messages. A signature from a user on server1 can be verified by a + user on server2. - **Nick namespacing**: In federated mode, nicks include a server suffix - (`nick@server`) to prevent collisions. Within a single server, bare nicks - are used. + (`nick@server`) to prevent collisions. Within a single server, bare nicks are + used. ### State Synchronization @@ -2097,14 +2174,14 @@ This mirrors IRC's server burst protocol. ### S2S Commands -| Command | Description | -|----------|-------------| +| Command | Description | +| -------- | ---------------------------------- | | `RELAY` | Relay a message from a remote user | -| `LINK` | Establish server link | -| `UNLINK` | Tear down server link | +| `LINK` | Establish server link | +| `UNLINK` | Tear down server link | | `SYNC` | Request full state synchronization | -| `PING` | Inter-server keepalive | -| `PONG` | Inter-server keepalive response | +| `PING` | Inter-server keepalive | +| `PONG` | Inter-server keepalive response | ### Federation Endpoints @@ -2135,140 +2212,145 @@ The database schema is managed via embedded SQL migration files in **Current tables:** #### `sessions` -| Column | Type | Description | -|----------------|----------|-------------| -| `id` | INTEGER | Primary key (auto-increment) | -| `uuid` | TEXT | Unique session UUID | -| `nick` | TEXT | Unique nick | -| `username` | TEXT | IRC ident/username portion of the hostmask (defaults to nick) | -| `hostname` | TEXT | Reverse DNS hostname of the connecting client IP | -| `ip` | TEXT | Real IP address of the session creator | -| `is_oper` | INTEGER | Server operator (o-line) status (0 = no, 1 = yes) | -| `password_hash`| TEXT | bcrypt hash (empty string for anonymous sessions) | -| `signing_key` | TEXT | Public signing key (empty string if unset) | -| `away_message` | TEXT | Away message (empty string if not away) | -| `created_at` | DATETIME | Session creation time | -| `last_seen` | DATETIME | Last API request time | + +| Column | Type | Description | +| --------------- | -------- | ------------------------------------------------------------- | +| `id` | INTEGER | Primary key (auto-increment) | +| `uuid` | TEXT | Unique session UUID | +| `nick` | TEXT | Unique nick | +| `username` | TEXT | IRC ident/username portion of the hostmask (defaults to nick) | +| `hostname` | TEXT | Reverse DNS hostname of the connecting client IP | +| `ip` | TEXT | Real IP address of the session creator | +| `is_oper` | INTEGER | Server operator (o-line) status (0 = no, 1 = yes) | +| `password_hash` | TEXT | bcrypt hash (empty string for anonymous sessions) | +| `signing_key` | TEXT | Public signing key (empty string if unset) | +| `away_message` | TEXT | Away message (empty string if not away) | +| `created_at` | DATETIME | Session creation time | +| `last_seen` | DATETIME | Last API request time | Index on `(uuid)`. #### `clients` -| Column | Type | Description | -|--------------|----------|-------------| -| `id` | INTEGER | Primary key (auto-increment) | -| `uuid` | TEXT | Unique client UUID | -| `session_id` | INTEGER | FK → sessions.id (cascade delete) | + +| Column | Type | Description | +| ------------ | -------- | ---------------------------------------------------------- | +| `id` | INTEGER | Primary key (auto-increment) | +| `uuid` | TEXT | Unique client UUID | +| `session_id` | INTEGER | FK → sessions.id (cascade delete) | | `token` | TEXT | Auth cookie value (SHA-256 hash of the 64-hex-char cookie) | -| `ip` | TEXT | Real IP address of this client connection | -| `hostname` | TEXT | Reverse DNS hostname of this client connection | -| `created_at` | DATETIME | Client creation time | -| `last_seen` | DATETIME | Last API request time | +| `ip` | TEXT | Real IP address of this client connection | +| `hostname` | TEXT | Reverse DNS hostname of this client connection | +| `created_at` | DATETIME | Client creation time | +| `last_seen` | DATETIME | Last API request time | Indexes on `(token)` and `(session_id)`. #### `channels` -| Column | Type | Description | -|---------------|----------|-------------| -| `id` | INTEGER | Primary key (auto-increment) | -| `name` | TEXT | Unique channel name (e.g., `#general`) | -| `topic` | TEXT | Channel topic (default empty) | -| `topic_set_by`| TEXT | Nick of the user who set the topic (default empty) | -| `topic_set_at`| DATETIME | When the topic was last set | -| `created_at` | DATETIME | Channel creation time | -| `updated_at` | DATETIME | Last modification time | + +| Column | Type | Description | +| -------------- | -------- | -------------------------------------------------- | +| `id` | INTEGER | Primary key (auto-increment) | +| `name` | TEXT | Unique channel name (e.g., `#general`) | +| `topic` | TEXT | Channel topic (default empty) | +| `topic_set_by` | TEXT | Nick of the user who set the topic (default empty) | +| `topic_set_at` | DATETIME | When the topic was last set | +| `created_at` | DATETIME | Channel creation time | +| `updated_at` | DATETIME | Last modification time | #### `channel_members` -| Column | Type | Description | -|--------------|----------|-------------| -| `id` | INTEGER | Primary key (auto-increment) | + +| Column | Type | Description | +| ------------ | -------- | --------------------------------- | +| `id` | INTEGER | Primary key (auto-increment) | | `channel_id` | INTEGER | FK → channels.id (cascade delete) | | `session_id` | INTEGER | FK → sessions.id (cascade delete) | -| `joined_at` | DATETIME | When the user joined | +| `joined_at` | DATETIME | When the user joined | Unique constraint on `(channel_id, session_id)`. #### `messages` -| Column | Type | Description | -|-------------|----------|-------------| -| `id` | INTEGER | Primary key (auto-increment). Internal ID for queue references. | -| `uuid` | TEXT | UUID v4, exposed to clients as the message `id` | -| `command` | TEXT | IRC command (`PRIVMSG`, `JOIN`, etc.) | -| `msg_from` | TEXT | Sender nick | -| `msg_to` | TEXT | Target (`#channel` or nick) | -| `params` | TEXT | JSON-encoded IRC-style positional parameters | -| `body` | TEXT | JSON-encoded body (array or object) | -| `meta` | TEXT | JSON-encoded metadata | -| `created_at`| DATETIME | Server timestamp | + +| Column | Type | Description | +| ------------ | -------- | --------------------------------------------------------------- | +| `id` | INTEGER | Primary key (auto-increment). Internal ID for queue references. | +| `uuid` | TEXT | UUID v4, exposed to clients as the message `id` | +| `command` | TEXT | IRC command (`PRIVMSG`, `JOIN`, etc.) | +| `msg_from` | TEXT | Sender nick | +| `msg_to` | TEXT | Target (`#channel` or nick) | +| `params` | TEXT | JSON-encoded IRC-style positional parameters | +| `body` | TEXT | JSON-encoded body (array or object) | +| `meta` | TEXT | JSON-encoded metadata | +| `created_at` | DATETIME | Server timestamp | Indexes on `(msg_to, id)` and `(created_at)`. #### `client_queues` -| Column | Type | Description | -|-------------|----------|-------------| -| `id` | INTEGER | Primary key (auto-increment). Used as the poll cursor. | -| `client_id` | INTEGER | FK → clients.id (cascade delete) | -| `message_id`| INTEGER | FK → messages.id (cascade delete) | -| `created_at`| DATETIME | When the entry was queued | + +| Column | Type | Description | +| ------------ | -------- | ------------------------------------------------------ | +| `id` | INTEGER | Primary key (auto-increment). Used as the poll cursor. | +| `client_id` | INTEGER | FK → clients.id (cascade delete) | +| `message_id` | INTEGER | FK → messages.id (cascade delete) | +| `created_at` | DATETIME | When the entry was queued | Unique constraint on `(client_id, message_id)`. Index on `(client_id, id)`. The `client_queues.id` is the monotonically increasing cursor used by -`GET /messages?after=`. This is more reliable than timestamps (no clock -skew issues) and simpler than UUIDs (integer comparison vs. string comparison). +`GET /messages?after=`. This is more reliable than timestamps (no clock skew +issues) and simpler than UUIDs (integer comparison vs. string comparison). ### Data Lifecycle -- **Messages**: Pruned automatically when older than `MESSAGE_MAX_AGE` - (default 30 days). +- **Messages**: Pruned automatically when older than `MESSAGE_MAX_AGE` (default + 30 days). - **Client output queue entries**: Pruned automatically when older than `QUEUE_MAX_AGE` (default 30 days). - **Channels**: Deleted when the last member leaves (ephemeral). -- **Sessions**: Both anonymous and password-protected sessions are deleted on `QUIT` - or when the last client logs out (`POST /api/v1/logout` with no remaining - clients triggers session cleanup). There is no distinction between session - types in the cleanup path — `handleQuit` and `cleanupUser` both call - `DeleteSession` unconditionally. Idle sessions are automatically expired - after `SESSION_IDLE_TIMEOUT` - (default 30 days) — the server runs a background cleanup loop that parts - idle users from all channels, broadcasts QUIT, and releases their nicks. -- **Clients**: Individual client auth cookies are invalidated on `POST /api/v1/logout`. - A session can have multiple clients; removing one doesn't affect others. - However, when the last client is removed (via logout), the entire session - is deleted — the user is parted from all channels, QUIT is broadcast, and - the nick is released. +- **Sessions**: Both anonymous and password-protected sessions are deleted on + `QUIT` or when the last client logs out (`POST /api/v1/logout` with no + remaining clients triggers session cleanup). There is no distinction between + session types in the cleanup path — `handleQuit` and `cleanupUser` both call + `DeleteSession` unconditionally. Idle sessions are automatically expired after + `SESSION_IDLE_TIMEOUT` (default 30 days) — the server runs a background + cleanup loop that parts idle users from all channels, broadcasts QUIT, and + releases their nicks. +- **Clients**: Individual client auth cookies are invalidated on + `POST /api/v1/logout`. A session can have multiple clients; removing one + doesn't affect others. However, when the last client is removed (via logout), + the entire session is deleted — the user is parted from all channels, QUIT is + broadcast, and the nick is released. --- ## Configuration All configuration is via environment variables, read by -[Viper](https://github.com/spf13/viper). A `.env` file in the working -directory is also loaded automatically via -[godotenv](https://github.com/joho/godotenv). +[Viper](https://github.com/spf13/viper). A `.env` file in the working directory +is also loaded automatically via [godotenv](https://github.com/joho/godotenv). -| Variable | Type | Default | Description | -|--------------------|---------|--------------------------------------|-------------| -| `PORT` | int | `8080` | HTTP listen port | -| `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. | -| `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) | -| `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. | -| `SESSION_IDLE_TIMEOUT` | string | `720h` | Session idle timeout as a Go duration string (e.g. `720h`, `24h`). Sessions with no activity for this long are expired and the nick is released. Default is 30 days. | -| `QUEUE_MAX_AGE` | string | `720h` | Maximum age of client output queue entries as a Go duration string (e.g. `720h`, `24h`). Entries older than this are pruned. Default is 30 days. | -| `MAX_MESSAGE_SIZE` | int | `4096` | Maximum message body size in bytes (planned enforcement) | -| `LONG_POLL_TIMEOUT`| int | `15` | Default long-poll timeout in seconds (client can override via query param, server caps at 30) | -| `MOTD` | string | `""` | Message of the day, shown to clients via `GET /api/v1/server` | -| `SERVER_NAME` | string | `""` | Server display name. Defaults to hostname if empty. | -| `FEDERATION_KEY` | string | `""` | Shared key for server federation linking (planned) | -| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) | -| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. | -| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint | -| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. | -| `NEOIRC_OPER_NAME` | string | `""` | Server operator (o-line) username. Both name and password must be set to enable OPER. | -| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. | -| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. | -| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. | -| `IRC_LISTEN_ADDR` | string | `:6667` | TCP address for the traditional IRC protocol listener. Set to empty string to disable. | -| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) | +| Variable | Type | Default | Description | +| ---------------------- | ------ | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PORT` | int | `8080` | HTTP listen port | +| `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. | +| `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) | +| `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. | +| `SESSION_IDLE_TIMEOUT` | string | `720h` | Session idle timeout as a Go duration string (e.g. `720h`, `24h`). Sessions with no activity for this long are expired and the nick is released. Default is 30 days. | +| `QUEUE_MAX_AGE` | string | `720h` | Maximum age of client output queue entries as a Go duration string (e.g. `720h`, `24h`). Entries older than this are pruned. Default is 30 days. | +| `MAX_MESSAGE_SIZE` | int | `4096` | Maximum message body size in bytes (planned enforcement) | +| `LONG_POLL_TIMEOUT` | int | `15` | Default long-poll timeout in seconds (client can override via query param, server caps at 30) | +| `MOTD` | string | `""` | Message of the day, shown to clients via `GET /api/v1/server` | +| `SERVER_NAME` | string | `""` | Server display name. Defaults to hostname if empty. | +| `FEDERATION_KEY` | string | `""` | Shared key for server federation linking (planned) | +| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) | +| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. | +| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint | +| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. | +| `NEOIRC_OPER_NAME` | string | `""` | Server operator (o-line) username. Both name and password must be set to enable OPER. | +| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. | +| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. | +| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. | +| `IRC_LISTEN_ADDR` | string | `:6667` | TCP address for the traditional IRC protocol listener. Set to empty string to disable. | +| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) | ### Example `.env` file @@ -2302,13 +2384,13 @@ IRC_LISTEN_ADDR= ### Supported Commands -| Category | Commands | -|------------|------------------------------------------------------| -| Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` | +| Category | Commands | +| ---------- | ------------------------------------------------------------------ | +| Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` | | Channels | `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE` | -| Messaging | `PRIVMSG`, `NOTICE` | -| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY` | -| Operator | `OPER` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) | +| Messaging | `PRIVMSG`, `NOTICE` | +| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY` | +| Operator | `OPER` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) | ### Protocol Details @@ -2322,15 +2404,15 @@ IRC_LISTEN_ADDR= automatically prepended. - **First joiner**: The first user to join a channel is automatically granted operator status (`@`). -- **Channel modes**: `+m` (moderated), `+t` (topic lock), `+o` (operator), - `+v` (voice) +- **Channel modes**: `+m` (moderated), `+t` (topic lock), `+o` (operator), `+v` + (voice) ### Bridge to HTTP API -Messages sent by IRC clients appear in channels visible to HTTP/JSON API -clients and vice versa. The IRC listener and HTTP API share the same database, -broker, and session infrastructure. A user connected via IRC and a user -connected via the HTTP API can communicate in the same channels seamlessly. +Messages sent by IRC clients appear in channels visible to HTTP/JSON API clients +and vice versa. The IRC listener and HTTP API share the same database, broker, +and session infrastructure. A user connected via IRC and a user connected via +the HTTP API can communicate in the same channels seamlessly. ### Docker Usage @@ -2347,6 +2429,35 @@ docker run -d \ --- +## Entrypoints + +This repository adheres to the +[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) +standard. Each script below has a `make` target of the same name that calls it, +except `script/install-precommit`, which is `make hooks`, and `script/cibuild`, +`script/precommit` and `script/projectname`, which have none. + +- `script/bootstrap`: installs what development needs: make, git, Node and yarn + (for prettier), and Go. +- `script/setup`: makes a fresh clone ready for development: runs + `script/bootstrap`, then `script/install-precommit`. +- `script/test`: builds the `Dockerfile`'s `test` phase, which runs the tests + with `-race`. +- `script/lint`: builds the `Dockerfile`'s `lint` phase, which runs + golangci-lint. +- `script/fmt`: formats the Go code with gofmt and the Markdown with prettier. +- `script/fmt-check`: the same, read-only; fails if anything would change. +- `script/check`: runs `script/test`, `script/lint` and `script/fmt-check`. +- `script/docker`: builds the image, tagged `neoirc`. +- `script/cibuild`: the CI build: `script/bootstrap`, `script/check`, then the + image. +- `script/precommit`: run by the git pre-commit hook: fails if `go mod tidy` + changes `go.mod` or `go.sum`, then runs `script/check`. +- `script/install-precommit`: installs that hook. +- `script/projectname`: prints the project name, `neoirc`. + +--- + ## Deployment ### Docker (Recommended) @@ -2355,7 +2466,7 @@ The Docker image contains a single static binary (`neoircd`) and nothing else. ```bash # Build -docker build -t neoirc . +make docker # Run docker run -p 8080:8080 \ @@ -2365,16 +2476,19 @@ docker run -p 8080:8080 \ neoirc ``` -The Dockerfile is a four-stage build: +The Dockerfile is a five-stage build: + 1. **web-builder**: Installs Node dependencies and compiles the SPA (JSX → bundled JS via esbuild) into `web/dist/` -2. **lint**: Runs formatting checks and golangci-lint against the Go source - (uses empty placeholder files for `web/dist/` so it runs independently of - web-builder for fast feedback) -3. **builder**: Runs tests and compiles static `neoircd` and `neoirc-cli` - binaries with the real SPA assets from web-builder (CLI built to verify - compilation, not included in final image) -4. **final**: Minimal Alpine image with only the `neoircd` binary +2. **lint**: Runs golangci-lint against the Go source (uses empty placeholder + files for `web/dist/` so it runs independently of web-builder for fast + feedback) +3. **test**: Runs the tests with `-race` on the Debian Go image, with the same + placeholder files +4. **builder**: Runs only once lint and test have passed; compiles static + `neoircd` and `neoirc-cli` binaries with the real SPA assets from web-builder + (CLI built to verify compilation, not included in final image) +5. **final**: Minimal Alpine image with only the `neoircd` binary ### Binary @@ -2393,6 +2507,7 @@ make build For production, run behind a TLS-terminating reverse proxy. **Caddy:** + ``` neoirc.example.com { reverse_proxy localhost:8080 @@ -2400,6 +2515,7 @@ neoirc.example.com { ``` **nginx:** + ```nginx server { listen 443 ssl; @@ -2426,9 +2542,11 @@ seconds to accommodate long-poll connections. string). This allows concurrent reads during writes. - **Single writer**: SQLite allows only one writer at a time. For high-traffic servers, Postgres support is planned. -- **Backup**: The database is a single file. Back it up with `sqlite3 /var/lib/neoirc/state.db ".backup backup.db"` or just copy the file (safe with WAL mode). -- **Location**: By default, `state.db` is created in `/var/lib/neoirc/`. - Use the `DBURL` env var to place it elsewhere. +- **Backup**: The database is a single file. Back it up with + `sqlite3 /var/lib/neoirc/state.db ".backup backup.db"` or just copy the file + (safe with WAL mode). +- **Location**: By default, `state.db` is created in `/var/lib/neoirc/`. Use the + `DBURL` env var to place it elsewhere. --- @@ -2514,16 +2632,19 @@ curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ The key to real-time messaging is the poll loop. Here's the pattern: ```python -# Python example — using requests.Session for automatic cookie handling +# Python example — using requests.Session for cookie handling import requests, json, time BASE = "http://localhost:8080/api/v1" -session = requests.Session() # Manages cookies automatically +session = requests.Session() last_id = 0 -# Create session (cookie set automatically via Set-Cookie header) +# Create session (the server sets the auth cookie via Set-Cookie) resp = session.post(f"{BASE}/session", json={"nick": "pybot"}) print(f"Session: {resp.json()}") +# The auth cookie is Secure, and requests sends it only over HTTPS. +# For a plain-HTTP server on localhost, send it on every request: +session.headers["Cookie"] = f"neoirc_auth={resp.cookies['neoirc_auth']}" # Join channel session.post(f"{BASE}/messages", @@ -2551,23 +2672,25 @@ while True: ```javascript // JavaScript/browser example — cookies sent automatically async function pollLoop() { - let lastId = 0; - while (true) { - try { - const resp = await fetch( - `/api/v1/messages?after=${lastId}&timeout=15`, - {credentials: 'same-origin'} // Include cookies - ); - if (resp.status === 401) { /* session expired */ break; } - const data = await resp.json(); - if (data.last_id) lastId = data.last_id; - for (const msg of data.messages || []) { - handleMessage(msg); - } - } catch (e) { - await new Promise(r => setTimeout(r, 2000)); // back off + let lastId = 0; + while (true) { + try { + const resp = await fetch( + `/api/v1/messages?after=${lastId}&timeout=15`, + { credentials: "same-origin" }, // Include cookies + ); + if (resp.status === 401) { + /* session expired */ break; + } + const data = await resp.json(); + if (data.last_id) lastId = data.last_id; + for (const msg of data.messages || []) { + handleMessage(msg); + } + } catch (e) { + await new Promise((r) => setTimeout(r, 2000)); // back off + } } - } } ``` @@ -2575,21 +2698,21 @@ async function pollLoop() { Clients should handle these message commands from the queue: -| Command | Display As | -|-----------|------------| -| `PRIVMSG` | ` message text` | -| `NOTICE` | `-nick- message text` (do not auto-reply) | -| `JOIN` | `*** nick has joined #channel` | -| `PART` | `*** nick has left #channel (reason)` | -| `QUIT` | `*** nick has quit (reason)` | -| `NICK` | `*** oldnick is now known as newnick` | -| `TOPIC` | `*** nick set topic: new topic` | +| Command | Display As | +| --------- | ---------------------------------------------------------- | +| `PRIVMSG` | ` message text` | +| `NOTICE` | `-nick- message text` (do not auto-reply) | +| `JOIN` | `*** nick has joined #channel` | +| `PART` | `*** nick has left #channel (reason)` | +| `QUIT` | `*** nick has quit (reason)` | +| `NICK` | `*** oldnick is now known as newnick` | +| `TOPIC` | `*** nick set topic: new topic` | | Numerics | Display body text (e.g., welcome messages, error messages) | ### Error Handling -- **HTTP 401**: Auth cookie expired or invalid. Re-create session or - re-login (if a password was set). +- **HTTP 401**: Auth cookie expired or invalid. Re-create session or re-login + (if a password was set). - **HTTP 404**: Channel or user not found. - **HTTP 409**: Nick already taken (on session creation or NICK change). - **HTTP 400**: Malformed request. Check the `error` field in the response. @@ -2602,17 +2725,17 @@ Clients should handle these message commands from the queue: responses. 2. **Always use `after` parameter**: Start with `after=0`, then use `last_id` from each response. Never reset to 0 unless you want to re-read history. -3. **Handle your own echoed messages**: Channel messages and DMs are echoed - back to the sender. Your client will receive its own messages. Either - deduplicate by `id` or show them (which confirms delivery). -4. **DM tab logic**: When you receive a PRIVMSG where `to` is not a channel - (no `#` prefix), the DM tab should be keyed by the **other** user's nick: - if `from` is you, use `to`; if `from` is someone else, use `from`. +3. **Handle your own echoed messages**: Channel messages and DMs are echoed back + to the sender. Your client will receive its own messages. Either deduplicate + by `id` or show them (which confirms delivery). +4. **DM tab logic**: When you receive a PRIVMSG where `to` is not a channel (no + `#` prefix), the DM tab should be keyed by the **other** user's nick: if + `from` is you, use `to`; if `from` is someone else, use `from`. 5. **Reconnection**: If the poll loop fails with 401, the auth cookie is - invalid. For sessions without a password, create a new session. For - sessions with a password set (via PASS command), log in again via - `POST /api/v1/login` to get a fresh cookie on the same session. If it - fails with a network error, retry with backoff. + invalid. For sessions without a password, create a new session. For sessions + with a password set (via PASS command), log in again via `POST /api/v1/login` + to get a fresh cookie on the same session. If it fails with a network error, + retry with backoff. --- @@ -2629,18 +2752,17 @@ account registration, no IP-based rate limits that punish shared networks. 1. Client fetches server info: `GET /api/v1/server` returns a `hashcash_bits` field (e.g., `20`) indicating the required difficulty. -2. Client computes a hashcash stamp: find a counter value such that the - SHA-256 hash of the stamp string has the required number of leading zero - bits. -3. Client includes the stamp in the `pow_token` field of the JSON request body when creating - a session: `POST /api/v1/session`. +2. Client computes a hashcash stamp: find a counter value such that the SHA-256 + hash of the stamp string has the required number of leading zero bits. +3. Client includes the stamp in the `pow_token` field of the JSON request body + when creating a session: `POST /api/v1/session`. 4. Server validates the stamp: - - Version is `1` - - Claimed bits ≥ required bits - - Resource matches the server name - - Date is within 48 hours (not expired, not too far in the future) - - SHA-256 hash has the required leading zero bits - - Stamp has not been used before (replay prevention) + - Version is `1` + - Claimed bits ≥ required bits + - Resource matches the server name + - Date is within 48 hours (not expired, not too far in the future) + - SHA-256 hash has the required leading zero bits + - Stamp has not been used before (replay prevention) ### Stamp Format @@ -2650,14 +2772,14 @@ Standard hashcash format: 1:bits:date:resource::counter ``` -| Field | Description | -|------------|-------------| -| `1` | Version (always `1`) | -| `bits` | Claimed difficulty (must be ≥ server's `hashcash_bits`) | -| `date` | Date stamp in `YYMMDD` or `YYMMDDHHMMSS` format (UTC) | +| Field | Description | +| ---------- | ----------------------------------------------------------------- | +| `1` | Version (always `1`) | +| `bits` | Claimed difficulty (must be ≥ server's `hashcash_bits`) | +| `date` | Date stamp in `YYMMDD` or `YYMMDDHHMMSS` format (UTC) | | `resource` | The server name (from `GET /api/v1/server`; defaults to `neoirc`) | -| (empty) | Extension field (unused) | -| `counter` | Hex counter value found by the client to satisfy the PoW | +| (empty) | Extension field (unused) | +| `counter` | Hex counter value found by the client to satisfy the PoW | **Example stamp:** `1:20:260310:neoirc::3a2f1b` @@ -2689,7 +2811,8 @@ Both the embedded web SPA and the CLI client automatically handle hashcash: 1. Fetch `GET /api/v1/server` to read `hashcash_bits` 2. If `hashcash_bits > 0`, compute a valid stamp -3. Include the stamp in the `pow_token` field of the JSON body on `POST /api/v1/session` +3. Include the stamp in the `pow_token` field of the JSON body on + `POST /api/v1/session` The web SPA uses the Web Crypto API (`crypto.subtle.digest`) for SHA-256 computation with batched parallelism. The CLI client uses Go's `crypto/sha256`. @@ -2698,13 +2821,13 @@ computation with batched parallelism. The CLI client uses Go's `crypto/sha256`. Set `NEOIRC_HASHCASH_BITS` to control difficulty: -| Value | Effect | Approx. Client CPU | -|-------|--------|---------------------| -| `0` | Disabled (no proof-of-work required) | — | -| `16` | Light protection | ~1ms | -| `20` | Default — good balance | ~0.5–2s | -| `24` | Strong protection | ~10–30s | -| `28` | Very strong (may frustrate users) | ~2–10min | +| Value | Effect | Approx. Client CPU | +| ----- | ------------------------------------ | ------------------ | +| `0` | Disabled (no proof-of-work required) | — | +| `16` | Light protection | ~1ms | +| `20` | Default — good balance | ~0.5–2s | +| `24` | Strong protection | ~10–30s | +| `28` | Very strong (may frustrate users) | ~2–10min | Each additional bit doubles the expected work. An attacker creating 1000 sessions at difficulty 20 needs ~1000–2000 CPU-seconds; a legitimate user @@ -2712,17 +2835,17 @@ creating one session pays once and keeps their session. ### Why Hashcash and Not Rate Limits? -- **No state to track**: No IP tables, no token buckets, no sliding windows. - The server only needs to verify a single hash. +- **No state to track**: No IP tables, no token buckets, no sliding windows. The + server only needs to verify a single hash. - **Works through NATs and proxies**: Doesn't punish shared IPs (university campuses, corporate networks, Tor exits). Every client computes their own proof independently. - **Cost falls on the requester**: The server's verification cost is constant (one SHA-256 hash) regardless of difficulty. Only the client does more work. -- **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry. - No registration, no email, no phone number, no CAPTCHA. Just compute. -- **Language-agnostic**: SHA-256 is available in every programming language. - The proof computation is trivially implementable in any client. +- **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry. No + registration, no email, no phone number, no CAPTCHA. Just compute. +- **Language-agnostic**: SHA-256 is available in every programming language. The + proof computation is trivially implementable in any client. ### Login Rate Limiting @@ -2730,14 +2853,14 @@ The login endpoint (`POST /api/v1/login`) has per-IP rate limiting to prevent brute-force password attacks. This uses a token-bucket algorithm (`golang.org/x/time/rate`) with configurable rate and burst. -| Environment Variable | Default | Description | -|---------------------|---------|-------------| -| `LOGIN_RATE_LIMIT` | `1` | Allowed login attempts per second per IP | -| `LOGIN_RATE_BURST` | `5` | Maximum burst of login attempts per IP | +| Environment Variable | Default | Description | +| -------------------- | ------- | ---------------------------------------- | +| `LOGIN_RATE_LIMIT` | `1` | Allowed login attempts per second per IP | +| `LOGIN_RATE_BURST` | `5` | Maximum burst of login attempts per IP | When the limit is exceeded, the server returns **429 Too Many Requests** with a -`Retry-After: 1` header. Stale per-IP entries are automatically cleaned up -every 10 minutes. +`Retry-After: 1` header. Stale per-IP entries are automatically cleaned up every +10 minutes. > **⚠️ Security: Reverse Proxy Required for Production Use** > @@ -2796,10 +2919,13 @@ guess is borne by the server (bcrypt), not the client. ### Post-MVP (Planned) - [x] **Hashcash proof-of-work** for session creation (abuse prevention) -- [x] **Client output queue pruning** — delete old client output queue entries per `QUEUE_MAX_AGE` +- [x] **Client output queue pruning** — delete old client output queue entries + per `QUEUE_MAX_AGE` - [x] **Message rotation** — prune messages older than `MESSAGE_MAX_AGE` -- [x] **Channel modes** — enforce `+m` (moderated), `+t` (topic lock), `+n` (no external) -- [x] **Channel modes (tier 2)** — enforce `+i` (invite-only), `+s` (secret), `+b` (ban), `+k` (key), `+l` (limit) +- [x] **Channel modes** — enforce `+m` (moderated), `+t` (topic lock), `+n` (no + external) +- [x] **Channel modes (tier 2)** — enforce `+i` (invite-only), `+s` (secret), + `+b` (ban), `+k` (key), `+l` (limit) - [x] **User channel modes** — `+o` (operator), `+v` (voice) with NAMES prefixes - [x] **KICK command** — operator-only channel kick with broadcast - [x] **MODE command** — query and set channel/user modes @@ -2812,32 +2938,32 @@ guess is borne by the server (bcrypt), not the client. - [x] **LUSERS numerics** — 251/252/254/255 sent on connect and via /LUSERS - [x] **KICK command** — remove users from channels (operator-only) - [x] **Numeric replies** — send IRC numeric codes via the message queue - (001-005 welcome, 251-255 LUSERS, 311-319 WHOIS, 322-329 LIST/MODE, - 331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors) + (001-005 welcome, 251-255 LUSERS, 311-319 WHOIS, 322-329 LIST/MODE, + 331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors) - [ ] **Max message size enforcement** — reject oversized messages - [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag) -- [x] **Multi-client sessions** — set a password via PASS command, then - login from additional devices via `POST /api/v1/login` -- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for - all API authentication +- [x] **Multi-client sessions** — set a password via PASS command, then login + from additional devices via `POST /api/v1/login` +- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for all API + authentication ### Future (1.0+) - [ ] **PUBKEY command** — public key distribution - [ ] **Message signing** — Ed25519 signatures with JCS canonicalization - [ ] **TOFU key management** — client-side key caching and verification -- [ ] **E2E encryption for DMs** — end-to-end encrypted direct messages - using X25519 key exchange +- [ ] **E2E encryption for DMs** — end-to-end encrypted direct messages using + X25519 key exchange - [ ] **Federation** — server-to-server linking, message relay, state sync - [ ] **Postgres support** — for high-traffic deployments - [ ] **Image/file upload** — inline media via a separate upload endpoint, - referenced in message `meta` -- [ ] **Push notifications** — optional webhook/push for mobile clients - when messages arrive during disconnect + referenced in message `meta` +- [ ] **Push notifications** — optional webhook/push for mobile clients when + messages arrive during disconnect - [ ] **Message search** — full-text search over channel history - [x] **User info command** — WHOIS for querying user info and channels -- [ ] **Connection flood protection** — per-IP connection limits as a - complement to hashcash +- [ ] **Connection flood protection** — per-IP connection limits as a complement + to hashcash - [ ] **Invite system** — `INVITE` command for `+i` channels - [ ] **Ban system** — channel-level bans by nick pattern @@ -2845,7 +2971,8 @@ guess is borne by the server (bcrypt), not the client. ## Project Structure -Following [gohttpserver CONVENTIONS.md](https://git.eeqj.de/sneak/gohttpserver/src/branch/main/CONVENTIONS.md): +Following +[gohttpserver CONVENTIONS.md](https://git.eeqj.de/sneak/gohttpserver/src/branch/main/CONVENTIONS.md): ``` neoirc/ @@ -2900,8 +3027,10 @@ neoirc/ │ │ └── style.css │ └── dist/ # Generated at Docker build time (not committed) ├── schema/ # JSON Schema definitions (planned) +├── script/ # Entrypoints that the Makefile targets call ├── go.mod ├── go.sum +├── package.json # prettier, for make fmt and make fmt-check ├── Makefile ├── Dockerfile ├── CONVENTIONS.md @@ -2910,18 +3039,18 @@ neoirc/ ### Required Libraries -| Purpose | Library | -|------------|---------| -| DI | `go.uber.org/fx` | -| Router | `github.com/go-chi/chi` | -| Logging | `log/slog` (stdlib) | -| Config | `github.com/spf13/viper` | -| Env | `github.com/joho/godotenv/autoload` | -| CORS | `github.com/go-chi/cors` | -| Metrics | `github.com/prometheus/client_golang` | -| DB | `modernc.org/sqlite` + `database/sql` | -| UUIDs | `github.com/google/uuid` | -| Errors | `github.com/getsentry/sentry-go` (optional) | +| Purpose | Library | +| ---------- | ------------------------------------------------------- | +| DI | `go.uber.org/fx` | +| Router | `github.com/go-chi/chi` | +| Logging | `log/slog` (stdlib) | +| Config | `github.com/spf13/viper` | +| Env | `github.com/joho/godotenv/autoload` | +| CORS | `github.com/go-chi/cors` | +| Metrics | `github.com/prometheus/client_golang` | +| DB | `modernc.org/sqlite` + `database/sql` | +| UUIDs | `github.com/google/uuid` | +| Errors | `github.com/getsentry/sentry-go` (optional) | | TUI Client | `github.com/rivo/tview` + `github.com/gdamore/tcell/v2` | --- @@ -2933,16 +3062,15 @@ neoirc/ API is too complex. 2. **Passwords optional** — anonymous sessions are instant: pick a nick and - talk. No registration, no email verification. The cost of entry is a - hashcash proof, not bureaucracy. For users who want multi-client access - (multiple devices sharing one session), the PASS command sets a password - on the session — but it's never required. Identity verification at the - message layer uses cryptographic signing, independent of password status. + talk. No registration, no email verification. The cost of entry is a hashcash + proof, not bureaucracy. For users who want multi-client access (multiple + devices sharing one session), the PASS command sets a password on the session + — but it's never required. Identity verification at the message layer uses + cryptographic signing, independent of password status. -3. **IRC semantics over HTTP** — command names and numeric codes from - RFC 1459/2812. If you've built an IRC client or bot, you already know the - command vocabulary. The only new things are the JSON encoding and the - HTTP transport. +3. **IRC semantics over HTTP** — command names and numeric codes from RFC + 1459/2812. If you've built an IRC client or bot, you already know the command + vocabulary. The only new things are the JSON encoding and the HTTP transport. 4. **HTTP is the only transport** — no WebSockets, no raw TCP, no protocol negotiation. HTTP is universal, proxy-friendly, CDN-friendly, and works on @@ -2951,8 +3079,8 @@ neoirc/ 5. **Server holds state** — clients are stateless. Reconnect, switch devices, lose connectivity for hours — your messages are waiting in your client queue. - The server is the source of truth for session state, channel membership, - and message history. + The server is the source of truth for session state, channel membership, and + message history. 6. **Structured messages** — JSON with extensible metadata. Bodies are always objects or arrays, never raw strings. This enables deterministic @@ -2964,25 +3092,25 @@ neoirc/ culture already handles corrections inline ("s/typo/fix/"). 8. **Simple deployment** — single binary, SQLite default, zero mandatory - external dependencies. `docker run` and you're done. No Redis, no - RabbitMQ, no Kubernetes, no configuration management. + external dependencies. `docker run` and you're done. No Redis, no RabbitMQ, + no Kubernetes, no configuration management. 9. **No eternal logs** — history rotates. Chat should be ephemeral by default. - Channels disappear when empty. Sessions expire when idle. The server does - not aspire to be an archive. + Channels disappear when empty. Sessions expire when idle. The server does not + aspire to be an archive. 10. **Federation optional** — a single server works standalone. Linking is manual and opt-in, like IRC. There is no requirement to participate in a network. 11. **Signable messages** — optional Ed25519 signatures with TOFU key - distribution. Servers relay signatures without verification. Trust - decisions are made by clients, not servers. + distribution. Servers relay signatures without verification. Trust decisions + are made by clients, not servers. 12. **No magic** — the protocol has no special cases, no content-type - negotiation, no feature flags. Every message uses the same envelope. - Every command goes through the same endpoint. The simplest implementation - is also the correct one. + negotiation, no feature flags. Every message uses the same envelope. Every + command goes through the same endpoint. The simplest implementation is also + the correct one. --- @@ -3010,4 +3138,3 @@ MIT ## Author [@sneak](https://sneak.berlin) - diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md index 8478553..20382d1 100644 --- a/REPO_POLICIES.md +++ b/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-03-09 +last_modified: 2026-10-04 --- This document covers repository structure, tooling, and workflow standards. Code @@ -34,10 +34,57 @@ style conventions are in separate documents: every file before committing. There are zero exceptions to this rule. - Every repo with software must have a root `Makefile` with these targets: - `make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only), - `make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and - `make hooks` (installs pre-commit hook). A model Makefile is at - `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`. + `make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes), + `make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`), + `make docker`, and `make hooks` (installs pre-commit hook). A model Makefile + is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`. + +- Repos follow the + [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) + pattern: the implementation of each Makefile target lives in an executable + script in `script/` (`script/bootstrap`, `script/setup`, `script/test`, + `script/lint`, `script/fmt`, `script/fmt-check`, `script/check`, + `script/docker`), and the Makefile targets are thin shims that call them. The + scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in + minimal containers (e.g. alpine images have no bash); locate the repo root + with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From + the standard's canonical set we use `bootstrap`, `setup` (make the repo ready + for development after a fresh clone: runs `bootstrap`, then + `install-precommit`, plus any repo-specific initialization), `test`, and + `cibuild`. `script/bootstrap` installs all dependencies idempotently and + assumes nothing is present: base tools come from nix, apt, brew, or apk + (detected in that order; apt runs noninteractive). For node it uses the + installed node if present; otherwise it installs a PINNED node version via + nvm, first installing nvm itself if missing — from a hash-verified GitHub + release archive (never `curl | sh`), with bash installed as an explicit + prerequisite since nvm requires bash. yarn is then pinned via + `corepack prepare yarn@ --activate`. Never install "latest" or "lts"; + always exact versions. `script/cibuild` runs the CI build: it changes to the + repo root, runs `script/bootstrap`, runs `script/check`, and builds the image + with the version; the Gitea workflow calls it. **`script/cibuild` runs + `script/bootstrap` first**, because the workflow checks out the repo and runs + nothing else, while `script/fmt-check` runs the formatter on the host: on a + pristine checkout with nothing installed the run dies there, after the + containerised gates have passed. **The bootstrap alone is not enough**: + `script/bootstrap` installs node and yarn under nvm and leaves neither on the + `PATH` of the shell that called it, so a bare `yarn` still exits 127. The host + entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore + source nvm for the pinned node version before invoking it, exactly as + `script/bootstrap`'s own install step does. A runner carrying nothing but + docker and git then gets through `script/check`. Four further scripts are our + own extensions to the standard: `script/check` runs `script/test`, + `script/lint` and `script/fmt-check`; `script/precommit` is what the git + pre-commit hook runs, and it calls `script/check`; `script/install-precommit` + installs the git pre-commit hook (the `make hooks` target shims to it); and + `script/projectname` (literally that filename) simply outputs the project's + name. Scripts that need the name call `script/projectname` — e.g. + `script/docker` assembles its image tag from it — so those scripts stay + byte-identical across all repos. Repo-type-specific pre-commit extras (e.g. + `go mod tidy` verification in Go repos) belong in `script/precommit`, not in + the hook itself. Model scripts are at + `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/`. The README + must document the provided scripts in an **Entrypoints** section (see the + README requirements below). - Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.) instead of invoking the underlying tools directly. The Makefile is the single @@ -53,15 +100,198 @@ style conventions are in separate documents: contributor should be able to understand the entire development workflow by reading the Makefile. -- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check` - as a build step so the build fails if the branch is not green. For non-server - repos, the Dockerfile should bring up a development environment and run - `make check`. For server repos, `make check` should run as an early build - stage before the final image is assembled. +- Every repo should have a `Dockerfile`, and it carries the repo's gates: a + `lint` phase and a `test` phase, with the final stage depending on both so the + image cannot be built unless they pass. For non-server repos the final stage + brings up a development environment; for server repos it is the runtime image. + The gate phases and the build stage start from their pinned base images and + install what those images lack either inline, as the canonical Go `Dockerfile` + below does for `git`, or by running `script/bootstrap`, as the `prompts` + repo's own `Dockerfile` does for its yarn packages. The development + environment stage installs development prerequisites by running + `script/bootstrap` rather than duplicating its installs inline. A stage that + runs `script/bootstrap` COPYs `script/` and the dependency manifests + (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it. + +- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is + no separate lint file. `script/lint` and `script/test` each build one phase + and nothing else: + + ```sh + docker build --no-cache --target lint -t "$(script/projectname)-lint" . + docker build --no-cache --target test -t "$(script/projectname)-test" . + ``` + + **A stage that is not the last one in the file is built only when the final + stage's chain depends on it, or when `--target` names it.** That is why the + two gates are always invoked by name here, and why the final stage carries a + `COPY --from=` of a harmless file from each of them: without that edge a + plain `docker build .` builds the last stage alone and exits 0 having linted + and tested nothing. + + **Every `docker build` in `script/` is tagged**, here and in + `script/cibuild` and `script/docker`. An untagged build leaves a dangling + image behind on every invocation, on every developer host and every CI + runner; a tagged one replaces the previous image. + + Inside a phase the tool is invoked directly — `golangci-lint`, `go test`, + `eslint`, `prettier` — never through `make lint` or `script/test`, which are + themselves a `docker build` and would recurse into a daemon that does not + exist in a build step. Formatting is the exception and stays on the host: + `script/fmt` writes the working tree, and `script/fmt-check` is its + read-only twin. + + **No lint verdict may come from a host invocation of the linter.** On a + shared host golangci-lint reads a result cache keyed on file content rather + than location, so a second checkout of the same content is served the first + one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs + exit non-zero with `parallel golangci-lint is running` — a status a caller + cannot tell from real findings. Both have produced wrong verdicts in this + org, in both directions. A container has its own cache, its own `TMPDIR` and + a digest-pinned binary, so neither is reachable. + +- **Any build that runs checks is built with `--no-cache`.** Docker invalidates + a `COPY` layer only when the copied content changes, so on an unchanged tree + the check `RUN` is served from cache, nothing executes, and the build still + exits 0. Every `docker build` in `script/` therefore passes `--no-cache`: + `script/lint`, `script/test`, `script/cibuild` and `script/docker` are the + four, and there is no fifth — `script/check` runs the two gate phases and + `script/fmt-check`, and builds no image of its own. A bare `docker build .` is + not evidence that anything ran: a sub-second build reporting success is a + cache hit, not a result. Never invalidate by pruning — `docker builder prune` + and friends destroy a build cache shared with every other build on the host. + When a check is added or changed, prove it works by planting a defect it must + catch and watching the run fail on it, then revert the defect. A green run + alone shows neither that the check ran nor that it covers what it should. + +- **The gate phases are separate stages, and the build stage depends on both.** + The lint phase is based on the `golangci/golangci-lint` image (pinned by + hash), so lint failures surface in seconds rather than after a full compile, + and the test phase is based on the Debian Go image. The canonical Go repo + `Dockerfile`: + + ```dockerfile + # Lint phase + # golangci/golangci-lint:v2.x.x, YYYY-MM-DD + FROM golangci/golangci-lint@sha256:... AS lint + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + COPY . . + RUN golangci-lint run --config .golangci.yml ./... + + # Test phase. -race needs cgo and so a C compiler, which the Debian Go + # image ships and the alpine one does not. + # golang:1.x, YYYY-MM-DD + FROM golang@sha256:... AS test + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + COPY . . + RUN go test -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -timeout 90s -race -v ./...; exit 1; } + + # Build stage. Nothing is wanted from either phase above; the copies + # are what make BuildKit build them first, so this stage cannot run + # unless lint and test passed. + # golang:1.x-alpine, YYYY-MM-DD + FROM golang@sha256:... AS builder + COPY --from=lint /src/go.sum /dev/null + COPY --from=test /src/go.sum /dev/null + RUN apk add --no-cache git + # A tar-stream context keeps the sender's file owners, which git refuses. + RUN git config --system --add safe.directory /src + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + COPY . . + + # The VERSION build arg when one is given, otherwise + # `git describe --tags --always` on the .git in the build context. With + # .git present, a version that is still empty, dev or unknown fails the + # build: git is missing or could not read the checkout. + ARG VERSION + RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \ + if [ -e .git ]; then \ + case "$VERSION" in ""|dev|unknown) \ + echo "version is '$VERSION' although .git is present" >&2; \ + exit 1 ;; \ + esac; \ + fi; \ + CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o /app ./cmd/app/ + + # Runtime stage, and the last one + FROM alpine@sha256:... + COPY --from=builder /app /usr/local/bin/app + ENTRYPOINT ["app"] + ``` + + Key points: + - The lint phase uses the `golangci/golangci-lint` image directly (it has + both Go and the linter), so nothing needs installing. + - `COPY --from= /src/go.sum /dev/null` is a no-op copy whose only + purpose is the ordering edge. BuildKit runs stages in parallel by default, + and a stage nothing depends on is not built at all, so without these two + lines a red gate would not fail the build. + - Keep the runtime stage last, and if you add a stage after it, give it the + same two copies. A plain `docker build .` builds the last stage's chain + and nothing else. + - If the project uses `//go:embed` directives that reference build artifacts + (e.g. a web frontend compiled in a separate stage), the lint phase must + create placeholder files so the embed directives resolve. Example: + `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. + - If the project requires CGO or system libraries for linting, install them + in the lint phase. The `golangci/golangci-lint` image is Debian-based and + has no `apk`, so install with `apt-get` under the Debian package name + (`libvips-dev`, where alpine says `vips-dev`), and delete the package + lists in the same `RUN`, so the layer does not keep them: + + ```dockerfile + RUN apt-get update \ + && apt-get install -y --no-install-recommends libvips-dev \ + && rm -rf /var/lib/apt/lists/* + ``` + + - `.dockerignore` lets `.git` into the build context. It keeps out every git + `config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the + repository's own, each submodule's under `.git/modules/`, and that of a + submodule keeping its own `.git` directory. `git describe` does not need + them, and each can hold a credential: a password in a remote URL, or the + token the CI checkout step stores there. A submodule whose name has a + `config` segment (`config`, `deploy/config`, `config/lib`) loses its whole + git directory to `**/.git/modules/**/config`, and Go's version stamping + then fails the build: give it a name without that segment + (`git submodule add --name`). The stage that compiles has `git` (the + Debian Go image has it; an alpine one needs `apk add --no-cache git`) and + takes the version from the `VERSION` build argument when one is given, + otherwise from `git describe --tags --always`. That gives the tag on a + tagged commit; on a later commit, the tag, the number of commits since it + and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no + tag is reachable. The stage that compiles also marks its working directory + safe for git (`git config --system --add safe.directory /src`): a context + sent as a tar stream keeps the sender's file owners, and git refuses a + checkout owned by another user, so the version would come out empty. + `ARG VERSION` has no default, and the build fails if the context carries + `.git` and the version still comes out empty, `dev` or `unknown`. A plain + `docker build .` with no build arguments must succeed; a Dockerfile that + refuses an empty build argument drops that refusal and keeps the argument. - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - runs `docker build .` on push. Since the Dockerfile already runs `make check`, - a successful build implies all checks pass. + runs `script/cibuild` on push, and checks out the repo as its only other step. + That script bootstraps, runs the gate phases, and then builds the image, so a + successful run means every check passed; a bare `docker build .` does not + carry the same guarantee, because its gate phases may come from the cache. The + image build is uncached and so runs the gate phases a second time. That is the + price of the rule above, and it is worth paying: the image that ships is built + from a run of its own gates rather than from a cache entry. A separate + workflow limited to `main` by a `branches` list under `on: push` cannot be + checked by review: to try a change to it, add the feature branch to that list + and push, then remove the branch from the list again before merging. Keep any + job in it that publishes behind `if: github.ref_name == 'main'`, so the run + from the feature branch publishes nothing. - Use platform-standard formatters: `black` for Python, `prettier` for JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with @@ -69,9 +299,11 @@ style conventions are in separate documents: Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown, HTML, CSS) should also have `.prettierrc` and `.prettierignore`. -- Pre-commit hook: `make check` if local testing is possible, otherwise - `make lint && make fmt-check`. The Makefile should provide a `make hooks` - target to install the pre-commit hook. +- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local + testing is not possible in the repo, `script/precommit` may skip `script/test` + and run only `script/lint` and `script/fmt-check`. The hook is installed by + `script/install-precommit`; the Makefile must provide a `make hooks` target + that shims to it. - All repos with software must have tests that run via the platform-standard test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful @@ -79,8 +311,66 @@ style conventions are in separate documents: module under test to verify it compiles/parses. There is no excuse for `make test` to be a no-op. -- `make test` must complete in under 20 seconds. Add a 30-second timeout in the - Makefile. +- `make test` must complete in under 60 seconds. That is the hard cap, and a + suite that exceeds it fails. Under 20 seconds is the target. A suite between + 20 and 60 seconds is still green, but the overage must be filed as an + improvement bug against that repo. Add a 90-second timeout to the test + invocation (`go test -timeout 90s`). The backstop deliberately sits above the + hard cap so that it catches a genuinely hung test rather than a merely slow + one. + +- **The test command should use the conditional verbose rerun pattern.** Run + tests without `-v` (verbose) first. If tests fail, automatically rerun with + `-v` to show full output. This keeps CI logs and `docker build` output clean + on success (just package/suite summaries) while providing full diagnostic + detail on failure (every test case, every assertion). The command lives in the + `test` phase of the `Dockerfile`, since `script/test` builds that phase; the + Makefile form below is the same pattern for any repo-local invocation: + + ```makefile + test: + @ || \ + { echo "--- Rerunning with -v for details ---"; \ + ; exit 1; } + ``` + + Go example: + + ```makefile + test: + @go test -count=1 -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -count=1 -timeout 90s -race -v ./...; exit 1; } + ``` + + `-count=1` is required on both invocations: it defeats Go's test _result_ + cache, so neither run can report a stored pass in place of running the + tests. It leaves the build cache alone, so it costs the runtime of the suite + and no recompilation. + + That cache is Go's own, separate from Docker's layer cache. Go stores a + passing result in its cache directory (`GOCACHE`), and when the same tests + run again on unchanged code it prints that result, marked `(cached)`, + without running them. That matters on a developer's machine, where this + target runs and the directory lasts from one run to the next. The `test` + phase of the `Dockerfile` needs no `-count=1`: its base image holds no + result for this repo's tests and nothing before its `go test` step runs a + test, so there is nothing to replay. `--no-cache` (above) is what makes that + step run on an unchanged tree. + + Python example: + + ```makefile + test: + @python -m pytest || \ + { echo "--- Rerunning with -v for details ---"; \ + python -m pytest -v; exit 1; } + ``` + + The `exit 1` ensures the target always fails after a rerun — the first run + already proved the tests are broken, so the build must not pass even if a + flaky test happens to succeed on the second attempt. The rerun exists solely + for diagnostic output. - Docker builds must complete in under 5 minutes. @@ -93,10 +383,84 @@ style conventions are in separate documents: must be in `.gitignore`. No exceptions. - `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), - editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`. - Fetch the standard `.gitignore` from - `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up - a new repo. + editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`), + language build artifacts, and `node_modules/`. Fetch the standard `.gitignore` + from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when + setting up a new repo. These patterns are written to `.gitignore`'s own + semantics, in which an unanchored pattern already matches at every depth; they + are not a `.dockerignore` and must not be transplanted into one unmodified. + +- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns + across unmodified leaves secrets in the build context.** Docker matches with + `moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so + `*` does not cross `/` and a pattern without a leading `**/` is anchored at + the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key` + therefore excludes only the copies at the repository root, while `config/.env` + and `certs/server.key` still reach the context and can land in an image layer + — which is more dangerous than a short file with no secret patterns at all, + because it reads as solved and stops anyone looking. Give every + depth-independent pattern the `**/` prefix and leave only genuinely + root-anchored entries unprefixed: `.claude`, and the repo's own host-built + binary, written `/myapp` and never `**/myapp`, which would also match + `cmd/myapp/` and delete the package directory from the context. Matching is + case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so + secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`, + and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern + also catches something the build needs, re-include it with a negation + (`!docs/example.env`); deleting the pattern reopens the exposure for every + other file it covers. Fetch the standard `.dockerignore` from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend + it with the repo's own artifacts. + +- **In-repo agent scratch belongs in both files, written to each file's own + semantics.** `.claude/` holds one worktree per in-flight agent — an entire + additional checkout of the repo — so under `COPY . .` the build context + inflates by a multiple of the repo and another session's unreviewed work can + be copied into an image layer. In `.gitignore` the entry is `.claude/`, + unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/` + prefix, because the prefixed form would also delete any nested directory of + that name from the build. Anchoring carries a known gap that the canonical + `.dockerignore` states in its own comment, since consuming repos receive the + file and not the tracker: the directory is created in the agent's working + directory, so a repo running agents in subdirectories still ships + `services/api/.claude/` and must add its own anchored entry there. + +- **A plain `docker build .` of a clone stamps the version that + `git describe --tags --always` gives**, derived from the `.git` in the build + context as the canonical `Dockerfile` above shows. Without its failure check, + a missing `git` or an unreadable checkout would leave `-X main.Version=` empty + and the build would still exit 0. `script/docker` and `script/cibuild` pass + the version they compute on the host; it takes precedence. They do this + byte-identically across repos: + + ```sh + # Own line: a failing command substitution inside an argument does not + # trip `set -e`, so the inline form degrades to an empty constant. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$(script/projectname)" . + ``` + + `--always` makes an untagged repo yield an abbreviated commit hash rather + than failing, and the `[ -n "$version" ]` line is the single place the + fallback is applied — a live check that fires on a build from an export with + no `.git` and on a repository with no commits yet. Do not fold it into the + substitution as `|| echo unknown`, which makes the guard unreachable. The + Dockerfile's side is `ARG VERSION` in the stage that compiles, declared + there because `ARG` is stage-scoped; passing `VERSION` to a repo whose + Dockerfile declares no such `ARG` is ignored and costs nothing, which is why + the scripts stay byte-identical. One consequence for CI: the standard + checkout action clones shallow and fetches no tags, so a repo that embeds a + tag-derived version must set `fetch-depth: 0` on its checkout step. + +- **Verify `.dockerignore` by enumerating the image, not by reading the + patterns.** Plant files at the root _and_ at least two directories deep, build + a probe image that does `COPY . .`, and list what actually landed + (`docker run --rm --entrypoint find IMAGE /app`). The `transferring context` + size is not a substitute: a nested secret is a few bytes, and BuildKit + transfers only the delta from the previous build. - **No build artifacts in version control.** Code-derived data (compiled bundles, minified output, generated assets) must never be committed to the @@ -112,9 +476,56 @@ style conventions are in separate documents: - Make all changes on a feature branch. You can do whatever you want on a feature branch. -- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only - manually by the user. Fetch from - `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. +- `.golangci.yml` is standardized. The vendored copy in a consuming repo must + _NEVER_ be modified by an agent: fetch it from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it + byte-identical, so that no repo can quietly loosen its own linting. Linter + configuration changes are made to the canonical copy in the `prompts` repo and + reach consuming repos by re-vendoring; an agent may open a PR against + canonical, which only the user merges. One list is exempt from byte-identity, + because it cannot be written once for every repo: the `deny` list of the + `test-support` depguard rule, where a repo names its own test-support packages + by full import path. A repo adds entries there and changes nothing else, and a + re-vendor carries its entries forward. The canonical golangci-lint version is + v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base + image + (`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`, + which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go` + directive must not name a newer Go minor version than the one golangci-lint + was built with, or golangci-lint refuses to lint it: this release lints + `go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo + installs golangci-lint on the host. A repo sets the lint phase digest to the + one named here and re-vendors `.golangci.yml` in the same commit, whichever of + the two prompted the change: the canonical copy can name linters that an older + golangci-lint rejects, and a newer golangci-lint can add linters that + `default: all` switches on until the canonical copy disables them. + +- **`script/bootstrap` installs a pinned tool by comparing versions, never by + testing presence.** An `if ! command -v ; then install; fi` guard tests + `PATH` only, so on an already-provisioned machine the pin is inert and a + version bump is a silent no-op — while the Dockerfile, installing into a clean + image, gets the pinned version, so a local `make check` and `make docker` can + disagree about what the tool even is. The canonical form: + - compares the installed version against the pin over the **whole** version + token; a parser that stops at the first `-` reports `2.12.2` for a host + running `2.12.2-rc1` and skips the install; + - treats absent, non-zero, empty or unrecognised `--version` output as a + mismatch, so the failure direction is a redundant install and never a + skipped one; + - after installing, re-resolves the binary the way callers do — `hash -r`, + then through `PATH`, not through the directory the installer wrote to — + and fails naming the resolved path, since an install that a shadowing + binary hides succeeds while changing nothing any caller sees; + - is actually called, and prints the version on both success paths: a + function defined and never invoked has the same exit status and the same + empty output as one that worked. + + Keep it POSIX sh: no arrays, no `[[`, no `grep -P`. + + A Go tool a repo needs on the host is installed with `go install` pinned to + a commit hash (`go install @`). It is never tracked as + a `go.mod` tool dependency or through a `tools.go` file, either of which + pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`. - When pinning images or packages by hash, add a comment above the reference with the version and date (YYYY-MM-DD). @@ -128,12 +539,76 @@ style conventions are in separate documents: - Dockerized web services listen on port 8080 by default, overridable with `PORT`. +- **HTTP/web services must be hardened for production internet exposure before + tagging 1.0.** This means full compliance with security best practices + including, without limitation, all of the following: + - **Security headers** on every response: + - `Strict-Transport-Security` (HSTS) with `max-age` of at least one year + and `includeSubDomains`. + - `Content-Security-Policy` (CSP) with a restrictive default policy + (`default-src 'self'` as a baseline, tightened per-resource as + needed). Never use `unsafe-inline` or `unsafe-eval` unless + unavoidable, and document the reason. + - `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required). + Prefer the `frame-ancestors` CSP directive as the primary control. + - `X-Content-Type-Options: nosniff`. + - `Referrer-Policy: strict-origin-when-cross-origin` (or stricter). + - `Permissions-Policy` restricting access to browser features the + application does not use (camera, microphone, geolocation, etc.). + - **Request and response limits:** + - Maximum request body size enforced on all endpoints (e.g. Go + `http.MaxBytesReader`). Choose a sane default per-route; never accept + unbounded input. + - Maximum response body size where applicable (e.g. paginated APIs). + - `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend + against slowloris attacks. + - `WriteTimeout` on the `http.Server`. + - `IdleTimeout` on the `http.Server`. + - Per-handler execution time limits via `context.WithTimeout` or + chi/stdlib `middleware.Timeout`. + - **Authentication and session security:** + - Rate limiting on password-based authentication endpoints. API keys are + high-entropy and not susceptible to brute force, so they are exempt. + - CSRF tokens on all state-mutating HTML forms. API endpoints + authenticated via `Authorization` header (Bearer token, API key) are + exempt because the browser does not attach these automatically. + - Passwords stored using bcrypt, scrypt, or argon2 — never plain-text, + MD5, or SHA. + - Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or + `Strict`) attributes. + - **Reverse proxy awareness:** + - True client IP detection when behind a reverse proxy + (`X-Forwarded-For`, `X-Real-IP`). The application must accept + forwarded headers only from a configured set of trusted proxy + addresses — never trust `X-Forwarded-For` unconditionally. + - **CORS:** + - Authenticated endpoints must restrict `Access-Control-Allow-Origin` to + an explicit allowlist of known origins. Wildcard (`*`) is acceptable + only for public, unauthenticated read-only APIs. + - **Error handling:** + - Internal errors must never leak stack traces, SQL queries, file paths, + or other implementation details to the client. Return generic error + messages in production; detailed errors only when `DEBUG` is enabled. + - **TLS:** + - Services never terminate TLS directly. They are always deployed behind + a TLS-terminating reverse proxy. The service itself listens on plain + HTTP. However, HSTS headers and `Secure` cookie flags must still be + set by the application so that the browser enforces HTTPS end-to-end. + + This list is non-exhaustive. Apply defense-in-depth: if a standard security + hardening measure exists for HTTP services and is not listed here, it is + still expected. When in doubt, harden. + - `README.md` is the primary documentation. Required sections: - **Description**: First line must include the project name, purpose, category (web server, SPA, CLI tool, etc.), license, and author. Example: "µPaaS is an MIT-licensed Go web application by @sneak that receives git-frontend webhooks and deploys applications via Docker in realtime." - **Getting Started**: Copy-pasteable install/usage code block. + - **Entrypoints**: Opens by stating that the repo adheres to the + [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) + standard (with that link), then documents each provided `script/` + entrypoint and its purpose. - **Rationale**: Why does this exist? - **Design**: How is the program structured? - **TODO**: Update meticulously, even between commits. When planning, put @@ -164,12 +639,14 @@ style conventions are in separate documents: settings. - Avoid putting files in the repo root unless necessary. Root should contain - only project-level config files (`README.md`, `Makefile`, `Dockerfile`, - `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and - language-specific config). Everything else goes in a subdirectory. Canonical - subdirectory names: + only project-level config files (`README.md`, `AGENTS.md`, `Makefile`, + `Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, + and language-specific config). Everything else goes in a subdirectory. + Canonical subdirectory names: - `bin/` — executable scripts and tools - - `cmd/` — Go command entrypoints + - `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose + body is a single call into `internal/` or `pkg/`, no project logic in + `cmd/` - `configs/` — configuration templates and examples - `deploy/` — deployment manifests (k8s, compose, terraform) - `docs/` — documentation and markdown (README.md stays in root) @@ -188,8 +665,15 @@ style conventions are in separate documents: - `README.md`, `.git`, `.gitignore`, `.editorconfig` - `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo) - `Makefile` + - `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`, + `lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`, + `install-precommit`) - `Dockerfile`, `.dockerignore` - `.gitea/workflows/check.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml` - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - Python: `pyproject.toml` + +- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It + is never committed under a file or directory named after one agent tool, such + as `CLAUDE.md` or `.claude/`, and never split into separate memory files. diff --git a/internal/cli/api/client.go b/internal/cli/api/client.go index 0e21c3e..5dd5972 100644 --- a/internal/cli/api/client.go +++ b/internal/cli/api/client.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/http/cookiejar" "net/url" @@ -41,11 +42,34 @@ func NewClient(baseURL string) *Client { BaseURL: baseURL, HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine Timeout: httpTimeout, - Jar: jar, + Jar: loopbackJar{CookieJar: jar}, }, } } +// loopbackJar also sends the server's auth cookie, which is +// always Secure, over plain HTTP to a server on localhost, as +// curl does. Go 1.24's cookie jar sends a Secure cookie only +// over HTTPS. +type loopbackJar struct { + http.CookieJar +} + +// Cookies returns the cookies to send to target, treating a +// plain-HTTP target on localhost as HTTPS. +func (jar loopbackJar) Cookies(target *url.URL) []*http.Cookie { + host := target.Hostname() + loopback := host == "localhost" || net.ParseIP(host).IsLoopback() + + if target.Scheme == "http" && loopback { + secure := *target + secure.Scheme = "https" + target = &secure + } + + return jar.CookieJar.Cookies(target) +} + // CreateSession creates a new session on the server. // If the server requires hashcash proof-of-work, it // automatically fetches the difficulty and computes a diff --git a/internal/cli/api/client_test.go b/internal/cli/api/client_test.go new file mode 100644 index 0000000..8a42cd1 --- /dev/null +++ b/internal/cli/api/client_test.go @@ -0,0 +1,131 @@ +package neoircapi_test + +import ( + "context" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + api "sneak.berlin/go/neoirc/internal/cli/api" +) + +const cookieValue = "opaque-value" + +// newSessionServer starts a plain-HTTP server that, like +// neoircd, sets a Secure auth cookie when a session is +// created and answers GET /api/v1/state only when that +// cookie comes back. +func newSessionServer(t *testing.T) *httptest.Server { + t.Helper() + + mux := http.NewServeMux() + + mux.HandleFunc("GET /api/v1/server", func( + writer http.ResponseWriter, _ *http.Request, + ) { + _, _ = io.WriteString(writer, `{}`) + }) + + mux.HandleFunc("POST /api/v1/session", func( + writer http.ResponseWriter, _ *http.Request, + ) { + http.SetCookie(writer, &http.Cookie{ + Name: "neoirc_auth", + Value: cookieValue, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + }) + writer.WriteHeader(http.StatusCreated) + + _, _ = io.WriteString(writer, `{"id":1,"nick":"alice"}`) + }) + + mux.HandleFunc("GET /api/v1/state", func( + writer http.ResponseWriter, request *http.Request, + ) { + cookie, err := request.Cookie("neoirc_auth") + if err != nil || cookie.Value != cookieValue { + writer.WriteHeader(http.StatusUnauthorized) + + return + } + + _, _ = io.WriteString( + writer, `{"id":1,"nick":"alice","channels":[]}`, + ) + }) + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + return server +} + +func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) { + t.Parallel() + + server := newSessionServer(t) + + serverURL, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse server URL: %v", err) + } + + for _, host := range []string{"127.0.0.1", "localhost"} { + t.Run(host, func(t *testing.T) { + t.Parallel() + + client := api.NewClient( + "http://" + net.JoinHostPort(host, serverURL.Port()), + ) + + _, err := client.CreateSession("alice") + if err != nil { + t.Fatalf("create session: %v", err) + } + + _, err = client.GetState() + if err != nil { + t.Fatalf("state after creating the session: %v", err) + } + }) + } +} + +func TestClientWithholdsCookieOverPlainHTTPFromOtherHosts(t *testing.T) { + t.Parallel() + + server := newSessionServer(t) + + // neoirc.example is not loopback. Every connection the client + // opens to it goes to the test server instead. + client := api.NewClient("http://neoirc.example") + client.HTTPClient.Transport = &http.Transport{ + DialContext: func( + ctx context.Context, network, _ string, + ) (net.Conn, error) { + var dialer net.Dialer + + return dialer.DialContext( + ctx, network, server.Listener.Addr().String(), + ) + }, + } + + _, err := client.CreateSession("alice") + if err != nil { + t.Fatalf("create session: %v", err) + } + + // The server answers 401 when the auth cookie does not arrive. + _, err = client.GetState() + if err == nil || !strings.Contains(err.Error(), "401") { + t.Fatalf("state: got %v, want 401: cookie sent over plain HTTP", err) + } +} diff --git a/internal/db/errors.go b/internal/db/errors.go index 7f5ad49..de7768c 100644 --- a/internal/db/errors.go +++ b/internal/db/errors.go @@ -1,4 +1,3 @@ -// Package db provides database access and migration management. package db import ( diff --git a/internal/db/queries.go b/internal/db/queries.go index a74a94d..d840773 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -1150,7 +1150,8 @@ func scanMessages( code, _ := strconv.Atoi(msg.Command) msg.Code = code - if mt, err := irc.FromInt(code); err == nil { + mt, lookupErr := irc.FromInt(code) + if lookupErr == nil { msg.Command = mt.Name() } } @@ -1373,7 +1374,9 @@ func (database *Database) GetStaleOrphanSessions( for rows.Next() { var stale StaleSession - if err := rows.Scan(&stale.ID, &stale.Nick); err != nil { + + err = rows.Scan(&stale.ID, &stale.Nick) + if err != nil { return nil, fmt.Errorf( "scan stale session: %w", err, ) @@ -1382,7 +1385,8 @@ func (database *Database) GetStaleOrphanSessions( result = append(result, stale) } - if err := rows.Err(); err != nil { + err = rows.Err() + if err != nil { return nil, fmt.Errorf( "iterate stale sessions: %w", err, ) @@ -1905,9 +1909,10 @@ func (database *Database) ListChannelBans( for rows.Next() { var ban BanInfo - if scanErr := rows.Scan( + scanErr := rows.Scan( &ban.Mask, &ban.SetBy, &ban.CreatedAt, - ); scanErr != nil { + ) + if scanErr != nil { return nil, fmt.Errorf( "scan channel ban: %w", scanErr, ) @@ -1916,7 +1921,8 @@ func (database *Database) ListChannelBans( bans = append(bans, ban) } - if rowErr := rows.Err(); rowErr != nil { + rowErr := rows.Err() + if rowErr != nil { return nil, fmt.Errorf( "iterate channel bans: %w", rowErr, ) @@ -2247,11 +2253,12 @@ func (database *Database) ListAllChannelsWithCountsFiltered( for rows.Next() { var chanInfo ChannelInfoFull - if scanErr := rows.Scan( + scanErr := rows.Scan( &chanInfo.Name, &chanInfo.MemberCount, &chanInfo.Topic, - ); scanErr != nil { + ) + if scanErr != nil { return nil, fmt.Errorf( "scan channel: %w", scanErr, ) @@ -2260,7 +2267,8 @@ func (database *Database) ListAllChannelsWithCountsFiltered( channels = append(channels, chanInfo) } - if rowErr := rows.Err(); rowErr != nil { + rowErr := rows.Err() + if rowErr != nil { return nil, fmt.Errorf( "iterate channels: %w", rowErr, ) @@ -2308,11 +2316,12 @@ func (database *Database) GetSessionChannelsFiltered( for rows.Next() { var chanInfo ChannelInfo - if scanErr := rows.Scan( + scanErr := rows.Scan( &chanInfo.ID, &chanInfo.Name, &chanInfo.Topic, - ); scanErr != nil { + ) + if scanErr != nil { return nil, fmt.Errorf( "scan channel: %w", scanErr, ) @@ -2321,7 +2330,8 @@ func (database *Database) GetSessionChannelsFiltered( channels = append(channels, chanInfo) } - if rowErr := rows.Err(); rowErr != nil { + rowErr := rows.Err() + if rowErr != nil { return nil, fmt.Errorf( "iterate channels: %w", rowErr, ) diff --git a/internal/db/queries_test.go b/internal/db/queries_test.go index 459dc04..9c201d2 100644 --- a/internal/db/queries_test.go +++ b/internal/db/queries_test.go @@ -987,11 +987,12 @@ func TestGetOperCount(t *testing.T) { sid2, _, _, err := database.CreateSession( ctx, "user2", "", "", "", ) - _ = sid2 if err != nil { t.Fatal(err) } + _ = sid2 + // Initially zero opers. count, err := database.GetOperCount(ctx) if err != nil { @@ -1023,23 +1024,25 @@ func TestGetOperCount(t *testing.T) { func TestWildcardMatch(t *testing.T) { t.Parallel() + const hostmask = "nick!user@host" + tests := []struct { pattern string input string match bool }{ - {"*!*@*", "nick!user@host", true}, + {"*!*@*", hostmask, true}, {"*!*@*.example.com", "nick!user@foo.example.com", true}, {"*!*@*.example.com", "nick!user@other.net", false}, {"badnick!*@*", "badnick!user@host", true}, {"badnick!*@*", "goodnick!user@host", false}, - {"nick!user@host", "nick!user@host", true}, - {"nick!user@host", "nick!user@other", false}, + {hostmask, hostmask, true}, + {hostmask, "nick!user@other", false}, {"*", "anything", true}, - {"?ick!*@*", "nick!user@host", true}, + {"?ick!*@*", hostmask, true}, {"?ick!*@*", "nn!user@host", false}, // Case-insensitive. - {"Nick!*@*", "nick!user@host", true}, + {"Nick!*@*", hostmask, true}, } for _, tc := range tests { diff --git a/internal/handlers/api.go b/internal/handlers/api.go index a27afdc..3da43f3 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -125,21 +125,18 @@ func (hdlr *Handlers) authSession( } // setAuthCookie sets the authentication cookie on the -// response. +// response. It is always Secure: the server runs behind a +// TLS-terminating reverse proxy. func (hdlr *Handlers) setAuthCookie( writer http.ResponseWriter, - request *http.Request, token string, ) { - secure := request.TLS != nil || - request.Header.Get("X-Forwarded-Proto") == "https" - http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields Name: authCookieName, Value: token, Path: "/", HttpOnly: true, - Secure: secure, + Secure: true, SameSite: http.SameSiteStrictMode, }) } @@ -148,17 +145,13 @@ func (hdlr *Handlers) setAuthCookie( // the client. func (hdlr *Handlers) clearAuthCookie( writer http.ResponseWriter, - request *http.Request, ) { - secure := request.TLS != nil || - request.Header.Get("X-Forwarded-Proto") == "https" - http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields Name: authCookieName, Value: "", Path: "/", HttpOnly: true, - Secure: secure, + Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: -1, }) @@ -172,7 +165,7 @@ func (hdlr *Handlers) requireAuth( hdlr.authSession(request) if err != nil { hdlr.respondJSON(writer, request, map[string]any{ - "error": "not registered", + errorKey: "not registered", "numeric": irc.ErrNotRegistered, }, http.StatusUnauthorized) @@ -285,11 +278,11 @@ func (hdlr *Handlers) executeCreateSession( hdlr.deliverMOTD(request, clientID, sessionID, nick) - hdlr.setAuthCookie(writer, request, token) + hdlr.setAuthCookie(writer, token) hdlr.respondJSON(writer, request, map[string]any{ - "id": sessionID, - "nick": nick, + "id": sessionID, + nickKey: nick, }, http.StatusCreated) } @@ -643,7 +636,7 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc { hdlr.respondJSON(writer, request, map[string]any{ "id": sessionID, - "nick": nick, + nickKey: nick, "channels": channels, }, http.StatusOK) } @@ -1090,7 +1083,7 @@ func (hdlr *Handlers) dispatchQueryCommand( ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "error"}, + map[string]string{statusKey: statusError}, http.StatusOK) } } @@ -1112,7 +1105,7 @@ func (hdlr *Handlers) handlePrivmsg( ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "error"}, + map[string]string{statusKey: statusError}, http.StatusOK) return @@ -1127,7 +1120,7 @@ func (hdlr *Handlers) handlePrivmsg( ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "error"}, + map[string]string{statusKey: statusError}, http.StatusOK) return @@ -1169,7 +1162,7 @@ func (hdlr *Handlers) respondIRCError( ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "error"}, + map[string]string{statusKey: statusError}, http.StatusOK) } @@ -1257,7 +1250,7 @@ func (hdlr *Handlers) handleChannelMsg( hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"id": uuid, "status": "sent"}, + map[string]string{"id": uuid, statusKey: "sent"}, http.StatusOK) } @@ -1447,7 +1440,7 @@ func (hdlr *Handlers) handleDirectMsg( hdlr.respondJSON(writer, request, map[string]string{ - "id": result.UUID, "status": "sent", + "id": result.UUID, statusKey: "sent", }, http.StatusOK) } @@ -1522,7 +1515,7 @@ func (hdlr *Handlers) executeJoin( hdlr.respondJSON(writer, request, map[string]string{ - "status": "joined", + statusKey: "joined", "channel": channel, }, http.StatusOK) @@ -1679,6 +1672,7 @@ func (hdlr *Handlers) handlePart( // Extract reason from body for the service call. reason := "" + if body != nil { var lines []string if json.Unmarshal(body, &lines) == nil && @@ -1700,7 +1694,7 @@ func (hdlr *Handlers) handlePart( hdlr.respondJSON(writer, request, map[string]string{ - "status": "parted", + statusKey: "parted", "channel": channel, }, http.StatusOK) @@ -1739,7 +1733,7 @@ func (hdlr *Handlers) handleNick( if newNick == nick { hdlr.respondJSON(writer, request, map[string]string{ - "status": "ok", "nick": newNick, + statusKey: "ok", nickKey: newNick, }, http.StatusOK) @@ -1770,7 +1764,7 @@ func (hdlr *Handlers) executeNickChange( hdlr.respondJSON(writer, request, map[string]string{ - "status": "ok", "nick": newNick, + statusKey: "ok", nickKey: newNick, }, http.StatusOK) } @@ -1831,7 +1825,7 @@ func (hdlr *Handlers) handleTopic( hdlr.respondJSON(writer, request, map[string]string{ - "status": "ok", "topic": topic, + statusKey: "ok", "topic": topic, }, http.StatusOK) } @@ -1885,7 +1879,7 @@ func (hdlr *Handlers) dispatchInfoCommand( _ = target _ = bodyLines - okResp := map[string]string{"status": "ok"} + okResp := map[string]string{statusKey: "ok"} switch command { case irc.CmdMotd: @@ -1916,6 +1910,7 @@ func (hdlr *Handlers) handleQuit( body json.RawMessage, ) { reason := "Client quit" + if body != nil { var lines []string if json.Unmarshal(body, &lines) == nil && @@ -1928,10 +1923,10 @@ func (hdlr *Handlers) handleQuit( request.Context(), sessionID, nick, reason, ) - hdlr.clearAuthCookie(writer, request) + hdlr.clearAuthCookie(writer) hdlr.respondJSON(writer, request, - map[string]string{"status": "quit"}, + map[string]string{statusKey: "quit"}, http.StatusOK) } @@ -1963,7 +1958,7 @@ func (hdlr *Handlers) handleMode( ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) return @@ -2064,7 +2059,7 @@ func (hdlr *Handlers) queryChannelMode( hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -2241,7 +2236,7 @@ func (hdlr *Handlers) applyParameterizedMode( ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "error"}, + map[string]string{statusKey: statusError}, http.StatusOK) } } @@ -2307,7 +2302,7 @@ func (hdlr *Handlers) applyUserMode( ) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -2353,7 +2348,7 @@ func (hdlr *Handlers) setChannelFlag( ) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -2437,7 +2432,7 @@ func (hdlr *Handlers) setHashcashMode( ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -2486,7 +2481,7 @@ func (hdlr *Handlers) clearHashcashMode( ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -2591,7 +2586,7 @@ func (hdlr *Handlers) executeBanChange( } hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -2645,7 +2640,7 @@ func (hdlr *Handlers) listBans( hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -2709,7 +2704,7 @@ func (hdlr *Handlers) setChannelKeyMode( } hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -2758,7 +2753,7 @@ func (hdlr *Handlers) clearChannelKeyMode( } hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -2833,7 +2828,7 @@ func (hdlr *Handlers) setChannelLimitMode( } hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -2883,7 +2878,7 @@ func (hdlr *Handlers) clearChannelLimitMode( } hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -3050,7 +3045,7 @@ func (hdlr *Handlers) executeInvite( } hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -3119,7 +3114,7 @@ func (hdlr *Handlers) handleNames( hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -3172,7 +3167,7 @@ func (hdlr *Handlers) handleList( hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -3262,7 +3257,7 @@ func (hdlr *Handlers) executeWhois( hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -3287,7 +3282,7 @@ func (hdlr *Handlers) whoisNotFound( ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -3454,7 +3449,7 @@ func (hdlr *Handlers) handleWho( ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) return @@ -3496,7 +3491,7 @@ func (hdlr *Handlers) handleWho( hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -3512,7 +3507,7 @@ func (hdlr *Handlers) handleLusers( ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -3703,10 +3698,10 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc { ) } - hdlr.clearAuthCookie(writer, request) + hdlr.clearAuthCookie(writer) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } } @@ -3809,7 +3804,7 @@ func (hdlr *Handlers) handleOper( hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -3857,7 +3852,7 @@ func (hdlr *Handlers) handleAway( hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -3919,7 +3914,7 @@ func (hdlr *Handlers) handleKick( } hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -3943,10 +3938,7 @@ func (hdlr *Handlers) deliverWhoisIdle( return } - idleSeconds := int64(time.Since(lastSeen).Seconds()) - if idleSeconds < 0 { - idleSeconds = 0 - } + idleSeconds := max(int64(time.Since(lastSeen).Seconds()), 0) signonUnix := strconv.FormatInt( createdAt.Unix(), 10, diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index bdfabd5..b6635a8 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -12,6 +12,7 @@ import ( "net/http" "net/http/httptest" "os" + "slices" "strconv" "strings" "sync" @@ -33,6 +34,7 @@ import ( "sneak.berlin/go/neoirc/internal/server" "sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/stats" + "sneak.berlin/go/neoirc/pkg/irc" ) func TestMain(m *testing.M) { @@ -45,6 +47,9 @@ const ( bodyKey = "body" toKey = "to" statusKey = "status" + nickKey = "nick" + passwordKey = "password" + testPassword = "password123" privmsgCmd = "PRIVMSG" joinCmd = "JOIN" apiMessages = "/api/v1/messages" @@ -308,7 +313,9 @@ func doRequestAuth( } if cookie != "" { - request.AddCookie(&http.Cookie{ //nolint:exhaustruct // only name+value needed + // A request cookie carries only its name and value, so + // gosec's G124 attribute check does not apply. + request.AddCookie(&http.Cookie{ //nolint:exhaustruct,gosec // only name+value Name: authCookieName, Value: cookie, }) @@ -328,7 +335,7 @@ func (tserver *testServer) createSession( tserver.t.Helper() body, err := json.Marshal( - map[string]string{"nick": nick}, + map[string]string{nickKey: nick}, ) if err != nil { tserver.t.Fatalf("marshal session: %v", err) @@ -470,7 +477,7 @@ func postSession( t.Helper() body, err := json.Marshal( - map[string]string{"nick": nick}, + map[string]string{nickKey: nick}, ) if err != nil { t.Fatalf("marshal: %v", err) @@ -677,22 +684,24 @@ func TestAuthValidToken(t *testing.T) { t.Fatalf("expected 200, got %d", status) } - if result["nick"] != "authtest" { + if result[nickKey] != "authtest" { t.Fatalf( "expected nick authtest, got %v", - result["nick"], + result[nickKey], ) } } func TestJoinChannel(t *testing.T) { + const channel = "#test" + tserver := newTestServer(t) token := tserver.createSession("joiner") status, result := tserver.sendCommand( token, map[string]any{ - commandKey: joinCmd, toKey: "#test", + commandKey: joinCmd, toKey: channel, }, ) if status != http.StatusOK { @@ -701,7 +710,7 @@ func TestJoinChannel(t *testing.T) { ) } - if result["channel"] != "#test" { + if result["channel"] != channel { t.Fatalf( "expected #test, got %v", result["channel"], ) @@ -733,20 +742,22 @@ func TestJoinWithoutHash(t *testing.T) { } func TestPartChannel(t *testing.T) { + const channel = "#test" + tserver := newTestServer(t) token := tserver.createSession("parter") tserver.sendCommand( token, map[string]any{ - commandKey: joinCmd, toKey: "#test", + commandKey: joinCmd, toKey: channel, }, ) status, result := tserver.sendCommand( token, map[string]any{ - commandKey: "PART", toKey: "#test", + commandKey: "PART", toKey: channel, }, ) if status != http.StatusOK { @@ -755,7 +766,7 @@ func TestPartChannel(t *testing.T) { ) } - if result["channel"] != "#test" { + if result["channel"] != channel { t.Fatalf( "expected #test, got %v", result["channel"], ) @@ -787,15 +798,17 @@ func TestJoinMissingTo(t *testing.T) { } func TestChannelMessage(t *testing.T) { + const channel = "#test" + tserver := newTestServer(t) aliceToken := tserver.createSession("alice_msg") bobToken := tserver.createSession("bob_msg") tserver.sendCommand(aliceToken, map[string]any{ - commandKey: joinCmd, toKey: "#test", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(bobToken, map[string]any{ - commandKey: joinCmd, toKey: "#test", + commandKey: joinCmd, toKey: channel, }) _, _ = tserver.pollMessages(aliceToken, 0) @@ -805,7 +818,7 @@ func TestChannelMessage(t *testing.T) { aliceToken, map[string]any{ commandKey: privmsgCmd, - toKey: "#test", + toKey: channel, bodyKey: []string{"hello world"}, }, ) @@ -831,17 +844,19 @@ func TestChannelMessage(t *testing.T) { } func TestMessageMissingBody(t *testing.T) { + const channel = "#test" + tserver := newTestServer(t) token := tserver.createSession("nobody") tserver.sendCommand(token, map[string]any{ - commandKey: joinCmd, toKey: "#test", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(token, 0) status, _ := tserver.sendCommand(token, map[string]any{ - commandKey: privmsgCmd, toKey: "#test", + commandKey: privmsgCmd, toKey: channel, }) if status != http.StatusOK { t.Fatalf("expected 200, got %d", status) @@ -989,7 +1004,7 @@ func TestNickChange(t *testing.T) { status, result := tserver.sendCommand( token, map[string]any{ - commandKey: "NICK", + commandKey: irc.CmdNick, bodyKey: []string{"newnick"}, }, ) @@ -999,9 +1014,9 @@ func TestNickChange(t *testing.T) { ) } - if result["nick"] != "newnick" { + if result[nickKey] != "newnick" { t.Fatalf( - "expected newnick, got %v", result["nick"], + "expected newnick, got %v", result[nickKey], ) } } @@ -1011,7 +1026,7 @@ func TestNickSameAsCurrent(t *testing.T) { token := tserver.createSession("same_nick") status, _ := tserver.sendCommand(token, map[string]any{ - commandKey: "NICK", + commandKey: irc.CmdNick, bodyKey: []string{"same_nick"}, }) if status != http.StatusOK { @@ -1028,7 +1043,7 @@ func TestNickCollision(t *testing.T) { _, lastID := tserver.pollMessages(token, 0) status, _ := tserver.sendCommand(token, map[string]any{ - commandKey: "NICK", + commandKey: irc.CmdNick, bodyKey: []string{"taken_nick"}, }) if status != http.StatusOK { @@ -1052,7 +1067,7 @@ func TestNickInvalid(t *testing.T) { _, lastID := tserver.pollMessages(token, 0) status, _ := tserver.sendCommand(token, map[string]any{ - commandKey: "NICK", + commandKey: irc.CmdNick, bodyKey: []string{"bad nick!"}, }) if status != http.StatusOK { @@ -1076,7 +1091,7 @@ func TestNickEmptyBody(t *testing.T) { _, lastID := tserver.pollMessages(token, 0) status, _ := tserver.sendCommand( - token, map[string]any{commandKey: "NICK"}, + token, map[string]any{commandKey: irc.CmdNick}, ) if status != http.StatusOK { t.Fatalf("expected 200, got %d", status) @@ -1093,18 +1108,20 @@ func TestNickEmptyBody(t *testing.T) { } func TestTopic(t *testing.T) { + const channel = "#topictest" + tserver := newTestServer(t) token := tserver.createSession("topic_user") tserver.sendCommand(token, map[string]any{ - commandKey: joinCmd, toKey: "#topictest", + commandKey: joinCmd, toKey: channel, }) status, result := tserver.sendCommand( token, map[string]any{ - commandKey: "TOPIC", - toKey: "#topictest", + commandKey: irc.CmdTopic, + toKey: channel, bodyKey: []string{"Hello World Topic"}, }, ) @@ -1128,7 +1145,7 @@ func TestTopicMissingTo(t *testing.T) { _, lastID := tserver.pollMessages(token, 0) status, _ := tserver.sendCommand(token, map[string]any{ - commandKey: "TOPIC", + commandKey: irc.CmdTopic, bodyKey: []string{"topic"}, }) if status != http.StatusOK { @@ -1146,17 +1163,19 @@ func TestTopicMissingTo(t *testing.T) { } func TestTopicMissingBody(t *testing.T) { + const channel = "#topictest" + tserver := newTestServer(t) token := tserver.createSession("topicnobody") tserver.sendCommand(token, map[string]any{ - commandKey: joinCmd, toKey: "#topictest", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(token, 0) status, _ := tserver.sendCommand(token, map[string]any{ - commandKey: "TOPIC", toKey: "#topictest", + commandKey: irc.CmdTopic, toKey: channel, }) if status != http.StatusOK { t.Fatalf("expected 200, got %d", status) @@ -1189,7 +1208,7 @@ func TestTopicNonMember(t *testing.T) { status, _ := tserver.sendCommand( bobToken, map[string]any{ - commandKey: "TOPIC", + commandKey: irc.CmdTopic, toKey: "#topicpriv", bodyKey: []string{"Hijacked topic"}, }, @@ -1351,17 +1370,19 @@ func TestHistory(t *testing.T) { } func TestHistoryNonMember(t *testing.T) { + const channel = "#secret" + tserver := newTestServer(t) aliceToken := tserver.createSession("alice_hist") bobToken := tserver.createSession("bob_hist") // Alice creates and joins a channel. tserver.sendCommand(aliceToken, map[string]any{ - commandKey: joinCmd, toKey: "#secret", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(aliceToken, map[string]any{ commandKey: privmsgCmd, - toKey: "#secret", + toKey: channel, bodyKey: []string{"secret stuff"}, }) @@ -1466,15 +1487,17 @@ func TestChannelMembers(t *testing.T) { } func TestLongPoll(t *testing.T) { + const channel = "#longpoll" + tserver := newTestServer(t) aliceToken := tserver.createSession("lp_alice") bobToken := tserver.createSession("lp_bob") tserver.sendCommand(aliceToken, map[string]any{ - commandKey: joinCmd, toKey: "#longpoll", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(bobToken, map[string]any{ - commandKey: joinCmd, toKey: "#longpoll", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(bobToken, 0) @@ -1516,7 +1539,7 @@ func TestLongPoll(t *testing.T) { tserver.sendCommand(aliceToken, map[string]any{ commandKey: privmsgCmd, - toKey: "#longpoll", + toKey: channel, bodyKey: []string{"wake up!"}, }) @@ -1571,14 +1594,16 @@ func TestLongPollTimeout(t *testing.T) { } func TestEphemeralChannelCleanup(t *testing.T) { + const channel = "#ephemeral" + tserver := newTestServer(t) token := tserver.createSession("ephemeral") tserver.sendCommand(token, map[string]any{ - commandKey: joinCmd, toKey: "#ephemeral", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(token, map[string]any{ - commandKey: "PART", toKey: "#ephemeral", + commandKey: "PART", toKey: channel, }) resp, err := doRequestAuth( @@ -1603,8 +1628,8 @@ func TestEphemeralChannelCleanup(t *testing.T) { t.Fatalf("decode channels: %v", decErr) } - for _, channel := range channels { - if channel["name"] == "#ephemeral" { + for _, listed := range channels { + if listed["name"] == channel { t.Fatal( "ephemeral channel should be cleaned up", ) @@ -1630,7 +1655,7 @@ func TestConcurrentSessions(t *testing.T) { nick := fmt.Sprintf("conc_%d", index) body, err := json.Marshal( - map[string]string{"nick": nick}, + map[string]string{nickKey: nick}, ) if err != nil { errs <- fmt.Errorf( @@ -1903,7 +1928,7 @@ func TestPassCommand(t *testing.T) { status, result := tserver.sendCommand( token, map[string]any{ - commandKey: "PASS", + commandKey: irc.CmdPass, bodyKey: []string{"s3cure_pass"}, }, ) @@ -1931,7 +1956,7 @@ func TestPassCommandShortPassword(t *testing.T) { status, _ := tserver.sendCommand( token, map[string]any{ - commandKey: "PASS", + commandKey: irc.CmdPass, bodyKey: []string{"short"}, }, ) @@ -1959,7 +1984,7 @@ func TestPassCommandEmpty(t *testing.T) { // Try empty password — should fail. status, _ := tserver.sendCommand( token, - map[string]any{commandKey: "PASS"}, + map[string]any{commandKey: irc.CmdPass}, ) if status != http.StatusOK { t.Fatalf("expected 200, got %d", status) @@ -1982,13 +2007,13 @@ func TestLoginValid(t *testing.T) { token := tserver.createSession("loginuser") tserver.sendCommand(token, map[string]any{ - commandKey: "PASS", - bodyKey: []string{"password123"}, + commandKey: irc.CmdPass, + bodyKey: []string{testPassword}, }) // Login with nick + password. loginBody, err := json.Marshal(map[string]string{ - "nick": "loginuser", "password": "password123", + nickKey: "loginuser", passwordKey: testPassword, }) if err != nil { t.Fatal(err) @@ -2035,10 +2060,10 @@ func TestLoginValid(t *testing.T) { t.Fatalf("expected 200, got %d", status) } - if state["nick"] != "loginuser" { + if state[nickKey] != "loginuser" { t.Fatalf( "expected loginuser, got %v", - state["nick"], + state[nickKey], ) } } @@ -2050,15 +2075,15 @@ func TestLoginWrongPassword(t *testing.T) { token := tserver.createSession("wrongpwuser") tserver.sendCommand(token, map[string]any{ - commandKey: "PASS", + commandKey: irc.CmdPass, bodyKey: []string{"correctpass1"}, }) postJSONExpectStatus( t, tserver, "/api/v1/login", map[string]string{ - "nick": "wrongpwuser", - "password": "wrongpass12", + nickKey: "wrongpwuser", + passwordKey: "wrongpass12", }, http.StatusUnauthorized, ) @@ -2070,8 +2095,8 @@ func TestLoginNonexistentUser(t *testing.T) { postJSONExpectStatus( t, tserver, "/api/v1/login", map[string]string{ - "nick": "ghostuser", - "password": "password123", + nickKey: "ghostuser", + passwordKey: testPassword, }, http.StatusUnauthorized, ) @@ -2081,7 +2106,7 @@ func TestSessionCookie(t *testing.T) { tserver := newTestServer(t) body, err := json.Marshal( - map[string]string{"nick": "cookietest"}, + map[string]string{nickKey: "cookietest"}, ) if err != nil { t.Fatal(err) @@ -2128,6 +2153,11 @@ func TestSessionCookie(t *testing.T) { t.Fatal("cookie should be SameSite=Strict") } + // Secure even over plain HTTP, as this test server is. + if !authCookie.Secure { + t.Fatal("cookie should be Secure") + } + // Verify JSON body does NOT contain token. var result map[string]any @@ -2152,10 +2182,10 @@ func TestSessionStillWorks(t *testing.T) { t.Fatalf("expected 200, got %d", status) } - if state["nick"] != "anon_user" { + if state[nickKey] != "anon_user" { t.Fatalf( "expected anon_user, got %v", - state["nick"], + state[nickKey], ) } } @@ -2217,7 +2247,7 @@ func TestWhoisShowsHostInfo(t *testing.T) { _, lastID := tserver.pollMessages(queryToken, 0) tserver.sendCommand(queryToken, map[string]any{ - commandKey: "WHOIS", + commandKey: irc.CmdWhois, toKey: "whoisuser", }) @@ -2258,7 +2288,7 @@ func (tserver *testServer) createSessionWithUsername( tserver.t.Helper() body, err := json.Marshal(map[string]string{ - "nick": nick, + nickKey: nick, "username": username, }) if err != nil { @@ -2301,6 +2331,8 @@ func (tserver *testServer) createSessionWithUsername( } func TestWhoShowsHostInfo(t *testing.T) { + const channel = "#whotest" + tserver := newTestServer(t) whoToken := tserver.createSessionWithUsername( @@ -2308,20 +2340,20 @@ func TestWhoShowsHostInfo(t *testing.T) { ) tserver.sendCommand(whoToken, map[string]any{ - commandKey: joinCmd, toKey: "#whotest", + commandKey: joinCmd, toKey: channel, }) queryToken := tserver.createSession("whoquerier") tserver.sendCommand(queryToken, map[string]any{ - commandKey: joinCmd, toKey: "#whotest", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(queryToken, 0) tserver.sendCommand(queryToken, map[string]any{ commandKey: "WHO", - toKey: "#whotest", + toKey: channel, }) msgs, _ := tserver.pollMessages(queryToken, lastID) @@ -2375,7 +2407,7 @@ func TestSessionUsernameDefault(t *testing.T) { // WHOIS should show the nick as the username. tserver.sendCommand(queryToken, map[string]any{ - commandKey: "WHOIS", + commandKey: irc.CmdWhois, toKey: "defaultusr", }) @@ -2418,8 +2450,8 @@ func TestLoginRateLimitExceeded(t *testing.T) { for range 5 { loginBody, mErr := json.Marshal( map[string]string{ - "nick": "nosuchuser", - "password": "doesnotmatter", + nickKey: "nosuchuser", + passwordKey: "doesnotmatter", }, ) if mErr != nil { @@ -2441,7 +2473,7 @@ func TestLoginRateLimitExceeded(t *testing.T) { // The next request should be rate-limited. loginBody, err := json.Marshal(map[string]string{ - "nick": "nosuchuser", "password": "doesnotmatter", + nickKey: "nosuchuser", passwordKey: "doesnotmatter", }) if err != nil { t.Fatal(err) @@ -2479,13 +2511,13 @@ func TestLoginRateLimitAllowsNormalUse(t *testing.T) { token := tserver.createSession("normaluser") tserver.sendCommand(token, map[string]any{ - commandKey: "PASS", - bodyKey: []string{"password123"}, + commandKey: irc.CmdPass, + bodyKey: []string{testPassword}, }) // A single login should succeed without rate limiting. loginBody, err := json.Marshal(map[string]string{ - "nick": "normaluser", "password": "password123", + nickKey: "normaluser", passwordKey: testPassword, }) if err != nil { t.Fatal(err) @@ -2527,13 +2559,13 @@ func TestNickBroadcastToChannels(t *testing.T) { _, lastID := tserver.pollMessages(bobToken, 0) tserver.sendCommand(aliceToken, map[string]any{ - commandKey: "NICK", + commandKey: irc.CmdNick, bodyKey: []string{"nick_a_new"}, }) msgs, _ := tserver.pollMessages(bobToken, lastID) - if !findMessage(msgs, "NICK", "nick_a") { + if !findMessage(msgs, irc.CmdNick, "nick_a") { t.Fatalf( "bob didn't get nick change: %v", msgs, ) @@ -2596,17 +2628,19 @@ func TestChannelHashcashSetMode(t *testing.T) { } func TestChannelHashcashQueryMode(t *testing.T) { + const channel = "#hcquery" + tserver := newTestServer(t) token := tserver.createSession("hcquery_user") tserver.sendCommand(token, map[string]any{ - commandKey: joinCmd, toKey: "#hcquery", + commandKey: joinCmd, toKey: channel, }) // Set hashcash bits. tserver.sendCommand(token, map[string]any{ commandKey: modeCmd, - toKey: "#hcquery", + toKey: channel, bodyKey: []string{"+H", "5"}, }) @@ -2615,7 +2649,7 @@ func TestChannelHashcashQueryMode(t *testing.T) { // Query mode — should show +nH. tserver.sendCommand(token, map[string]any{ commandKey: modeCmd, - toKey: "#hcquery", + toKey: channel, }) msgs, _ := tserver.pollMessages(token, lastID) @@ -2638,24 +2672,26 @@ func TestChannelHashcashQueryMode(t *testing.T) { } func TestChannelHashcashClearMode(t *testing.T) { + const channel = "#hcclear" + tserver := newTestServer(t) token := tserver.createSession("hcclear_user") tserver.sendCommand(token, map[string]any{ - commandKey: joinCmd, toKey: "#hcclear", + commandKey: joinCmd, toKey: channel, }) // Set hashcash bits. tserver.sendCommand(token, map[string]any{ commandKey: modeCmd, - toKey: "#hcclear", + toKey: channel, bodyKey: []string{"+H", "5"}, }) // Clear hashcash bits. status, _ := tserver.sendCommand(token, map[string]any{ commandKey: modeCmd, - toKey: "#hcclear", + toKey: channel, bodyKey: []string{"-H"}, }) if status != http.StatusOK { @@ -2667,7 +2703,7 @@ func TestChannelHashcashClearMode(t *testing.T) { token, map[string]any{ commandKey: privmsgCmd, - toKey: "#hcclear", + toKey: channel, bodyKey: []string{"test message"}, }, ) @@ -2679,17 +2715,19 @@ func TestChannelHashcashClearMode(t *testing.T) { } func TestChannelHashcashRejectNoStamp(t *testing.T) { + const channel = "#hcreject" + tserver := newTestServer(t) token := tserver.createSession("hcreject_user") tserver.sendCommand(token, map[string]any{ - commandKey: joinCmd, toKey: "#hcreject", + commandKey: joinCmd, toKey: channel, }) // Set hashcash requirement. tserver.sendCommand(token, map[string]any{ commandKey: modeCmd, - toKey: "#hcreject", + toKey: channel, bodyKey: []string{"+H", "2"}, }) @@ -2700,7 +2738,7 @@ func TestChannelHashcashRejectNoStamp(t *testing.T) { token, map[string]any{ commandKey: privmsgCmd, - toKey: "#hcreject", + toKey: channel, bodyKey: []string{"spam message"}, }, ) @@ -2720,17 +2758,22 @@ func TestChannelHashcashRejectNoStamp(t *testing.T) { } func TestChannelHashcashAcceptValidStamp(t *testing.T) { + const ( + channel = "#hcaccept" + text = "hello world" + ) + tserver := newTestServer(t) token := tserver.createSession("hcaccept_user") tserver.sendCommand(token, map[string]any{ - commandKey: joinCmd, toKey: "#hcaccept", + commandKey: joinCmd, toKey: channel, }) // Set hashcash requirement (2 bits = fast to mint). tserver.sendCommand(token, map[string]any{ commandKey: modeCmd, - toKey: "#hcaccept", + toKey: channel, bodyKey: []string{"+H", "2"}, }) @@ -2738,14 +2781,14 @@ func TestChannelHashcashAcceptValidStamp(t *testing.T) { // Mint a valid hashcash stamp. msgBody, marshalErr := json.Marshal( - []string{"hello world"}, + []string{text}, ) if marshalErr != nil { t.Fatal(marshalErr) } stamp := mintTestChannelHashcash( - t, 2, "#hcaccept", msgBody, + t, 2, channel, msgBody, ) // Send message with valid hashcash. @@ -2753,8 +2796,8 @@ func TestChannelHashcashAcceptValidStamp(t *testing.T) { token, map[string]any{ commandKey: privmsgCmd, - toKey: "#hcaccept", - bodyKey: []string{"hello world"}, + toKey: channel, + bodyKey: []string{text}, metaKey: map[string]any{ hashcashKey: stamp, }, @@ -2780,17 +2823,22 @@ func TestChannelHashcashAcceptValidStamp(t *testing.T) { } func TestChannelHashcashRejectReplayedStamp(t *testing.T) { + const ( + channel = "#hcreplay" + text = "unique msg" + ) + tserver := newTestServer(t) token := tserver.createSession("hcreplay_user") tserver.sendCommand(token, map[string]any{ - commandKey: joinCmd, toKey: "#hcreplay", + commandKey: joinCmd, toKey: channel, }) // Set hashcash requirement. tserver.sendCommand(token, map[string]any{ commandKey: modeCmd, - toKey: "#hcreplay", + toKey: channel, bodyKey: []string{"+H", "2"}, }) @@ -2798,22 +2846,22 @@ func TestChannelHashcashRejectReplayedStamp(t *testing.T) { // Mint and send once — should succeed. msgBody, marshalErr := json.Marshal( - []string{"unique msg"}, + []string{text}, ) if marshalErr != nil { t.Fatal(marshalErr) } stamp := mintTestChannelHashcash( - t, 2, "#hcreplay", msgBody, + t, 2, channel, msgBody, ) status, _ := tserver.sendCommand( token, map[string]any{ commandKey: privmsgCmd, - toKey: "#hcreplay", - bodyKey: []string{"unique msg"}, + toKey: channel, + bodyKey: []string{text}, metaKey: map[string]any{ hashcashKey: stamp, }, @@ -2830,8 +2878,8 @@ func TestChannelHashcashRejectReplayedStamp(t *testing.T) { token, map[string]any{ commandKey: privmsgCmd, - toKey: "#hcreplay", - bodyKey: []string{"unique msg"}, + toKey: channel, + bodyKey: []string{text}, metaKey: map[string]any{ hashcashKey: stamp, }, @@ -2944,7 +2992,7 @@ func TestNamesShowsHostmask(t *testing.T) { // Issue an explicit NAMES command. tserver.sendCommand(queryToken, map[string]any{ - commandKey: "NAMES", + commandKey: irc.CmdNames, toKey: "#namestest", }) @@ -3089,7 +3137,7 @@ func TestOperCommandSuccess(t *testing.T) { // Send OPER command. tserver.sendCommand(token, map[string]any{ - commandKey: "OPER", + commandKey: irc.CmdOper, bodyKey: []string{testOperName, testOperPassword}, }) @@ -3112,7 +3160,7 @@ func TestOperCommandFailure(t *testing.T) { // Send OPER with wrong password. tserver.sendCommand(token, map[string]any{ - commandKey: "OPER", + commandKey: irc.CmdOper, bodyKey: []string{testOperName, "wrongpass"}, }) @@ -3135,7 +3183,7 @@ func TestOperCommandNeedMoreParams(t *testing.T) { // Send OPER with only one parameter. tserver.sendCommand(token, map[string]any{ - commandKey: "OPER", + commandKey: irc.CmdOper, bodyKey: []string{testOperName}, }) @@ -3162,7 +3210,7 @@ func TestOperWhoisShowsClientInfo(t *testing.T) { // Authenticate as oper. tserver.sendCommand(operToken, map[string]any{ - commandKey: "OPER", + commandKey: irc.CmdOper, bodyKey: []string{testOperName, testOperPassword}, }) @@ -3179,7 +3227,7 @@ func TestOperWhoisShowsClientInfo(t *testing.T) { // Now WHOIS the target. tserver.sendCommand(operToken, map[string]any{ - commandKey: "WHOIS", + commandKey: irc.CmdWhois, toKey: "target", }) @@ -3230,7 +3278,7 @@ func TestNonOperWhoisHidesClientInfo(t *testing.T) { // WHOIS the target without oper status. tserver.sendCommand(regToken, map[string]any{ - commandKey: "WHOIS", + commandKey: irc.CmdWhois, toKey: "hidden", }) @@ -3262,7 +3310,7 @@ func TestWhoisShowsOperatorStatus(t *testing.T) { _, lastID := tserver.pollMessages(operToken, 0) tserver.sendCommand(operToken, map[string]any{ - commandKey: "OPER", + commandKey: irc.CmdOper, bodyKey: []string{testOperName, testOperPassword}, }) @@ -3277,7 +3325,7 @@ func TestWhoisShowsOperatorStatus(t *testing.T) { _, queryLastID := tserver.pollMessages(queryToken, 0) tserver.sendCommand(queryToken, map[string]any{ - commandKey: "WHOIS", + commandKey: irc.CmdWhois, toKey: "iamoper", }) @@ -3301,7 +3349,7 @@ func TestOperNoOlineConfigured(t *testing.T) { _, lastID := tserver.pollMessages(token, 0) tserver.sendCommand(token, map[string]any{ - commandKey: "OPER", + commandKey: irc.CmdOper, bodyKey: []string{testOperName, "password"}, }) @@ -3335,7 +3383,7 @@ func TestOperatorAutoGrantOnCreate(t *testing.T) { // Issue NAMES — the creator should have @prefix. tserver.sendCommand(token, map[string]any{ - commandKey: "NAMES", toKey: "#opcreate", + commandKey: irc.CmdNames, toKey: "#opcreate", }) msgs, _ := tserver.pollMessages(token, lastID) @@ -3374,22 +3422,24 @@ func TestOperatorAutoGrantOnCreate(t *testing.T) { // TestSecondJoinerNotOperator verifies that subsequent // joiners do NOT get +o. func TestSecondJoinerNotOperator(t *testing.T) { + const channel = "#optest2" + tserver := newTestServer(t) creatorToken := tserver.createSession("op_first") joinerToken := tserver.createSession("op_second") tserver.sendCommand(creatorToken, map[string]any{ - commandKey: joinCmd, toKey: "#optest2", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(joinerToken, map[string]any{ - commandKey: joinCmd, toKey: "#optest2", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(joinerToken, 0) tserver.sendCommand(joinerToken, map[string]any{ - commandKey: "NAMES", toKey: "#optest2", + commandKey: irc.CmdNames, toKey: channel, }) msgs, _ := tserver.pollMessages(joinerToken, lastID) @@ -3426,15 +3476,17 @@ func TestSecondJoinerNotOperator(t *testing.T) { // TestModeGrantOperator tests MODE +o. func TestModeGrantOperator(t *testing.T) { + const channel = "#modeop" + tserver := newTestServer(t) opToken := tserver.createSession("granter") targetToken := tserver.createSession("grantee") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#modeop", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(targetToken, map[string]any{ - commandKey: joinCmd, toKey: "#modeop", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(targetToken, 0) @@ -3442,7 +3494,7 @@ func TestModeGrantOperator(t *testing.T) { // granter (creator = +o) grants +o to grantee. status, _ := tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#modeop", + toKey: channel, bodyKey: []string{"+o", "grantee"}, }) if status != http.StatusOK { @@ -3463,7 +3515,7 @@ func TestModeGrantOperator(t *testing.T) { _, lastID = tserver.pollMessages(targetToken, 0) tserver.sendCommand(targetToken, map[string]any{ - commandKey: "NAMES", toKey: "#modeop", + commandKey: irc.CmdNames, toKey: channel, }) msgs, _ = tserver.pollMessages(targetToken, lastID) @@ -3487,27 +3539,29 @@ func TestModeGrantOperator(t *testing.T) { // TestModeRevokeOperator tests MODE -o. func TestModeRevokeOperator(t *testing.T) { + const channel = "#revoke" + tserver := newTestServer(t) opToken := tserver.createSession("revoker") targetToken := tserver.createSession("revokee") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#revoke", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(targetToken, map[string]any{ - commandKey: joinCmd, toKey: "#revoke", + commandKey: joinCmd, toKey: channel, }) // Grant +o, then revoke it. tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#revoke", + toKey: channel, bodyKey: []string{"+o", "revokee"}, }) tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#revoke", + toKey: channel, bodyKey: []string{"-o", "revokee"}, }) @@ -3515,7 +3569,7 @@ func TestModeRevokeOperator(t *testing.T) { _, lastID := tserver.pollMessages(opToken, 0) tserver.sendCommand(opToken, map[string]any{ - commandKey: "NAMES", toKey: "#revoke", + commandKey: irc.CmdNames, toKey: channel, }) msgs, _ := tserver.pollMessages(opToken, lastID) @@ -3539,21 +3593,23 @@ func TestModeRevokeOperator(t *testing.T) { // TestModeVoice tests MODE +v/-v. func TestModeVoice(t *testing.T) { + const channel = "#voice" + tserver := newTestServer(t) opToken := tserver.createSession("voicer") targetToken := tserver.createSession("voiced") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#voice", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(targetToken, map[string]any{ - commandKey: joinCmd, toKey: "#voice", + commandKey: joinCmd, toKey: channel, }) // Grant +v. status, _ := tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#voice", + toKey: channel, bodyKey: []string{"+v", "voiced"}, }) if status != http.StatusOK { @@ -3564,7 +3620,7 @@ func TestModeVoice(t *testing.T) { _, lastID := tserver.pollMessages(opToken, 0) tserver.sendCommand(opToken, map[string]any{ - commandKey: "NAMES", toKey: "#voice", + commandKey: irc.CmdNames, toKey: channel, }) msgs, _ := tserver.pollMessages(opToken, lastID) @@ -3584,14 +3640,14 @@ func TestModeVoice(t *testing.T) { // Revoke -v. tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#voice", + toKey: channel, bodyKey: []string{"-v", "voiced"}, }) _, lastID = tserver.pollMessages(opToken, 0) tserver.sendCommand(opToken, map[string]any{ - commandKey: "NAMES", toKey: "#voice", + commandKey: irc.CmdNames, toKey: channel, }) msgs, _ = tserver.pollMessages(opToken, lastID) @@ -3612,19 +3668,21 @@ func TestModeVoice(t *testing.T) { // TestModeNonOpCannotGrant verifies non-operators // cannot grant +o or +v. func TestModeNonOpCannotGrant(t *testing.T) { + const channel = "#noperm" + tserver := newTestServer(t) opToken := tserver.createSession("realop") nonOpToken := tserver.createSession("nonop") targetToken := tserver.createSession("target3") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#noperm", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(nonOpToken, map[string]any{ - commandKey: joinCmd, toKey: "#noperm", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(targetToken, map[string]any{ - commandKey: joinCmd, toKey: "#noperm", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(nonOpToken, 0) @@ -3632,7 +3690,7 @@ func TestModeNonOpCannotGrant(t *testing.T) { // Non-op tries +o. tserver.sendCommand(nonOpToken, map[string]any{ commandKey: modeCmd, - toKey: "#noperm", + toKey: channel, bodyKey: []string{"+o", "target3"}, }) @@ -3652,7 +3710,7 @@ func TestModeNonOpCannotGrant(t *testing.T) { tserver.sendCommand(nonOpToken, map[string]any{ commandKey: modeCmd, - toKey: "#noperm", + toKey: channel, bodyKey: []string{"+v", "target3"}, }) @@ -3669,21 +3727,23 @@ func TestModeNonOpCannotGrant(t *testing.T) { // TestModeratedChannelBlocksNonVoiced tests +m mode. func TestModeratedChannelBlocksNonVoiced(t *testing.T) { + const channel = "#moderated" + tserver := newTestServer(t) opToken := tserver.createSession("modop") regularToken := tserver.createSession("regular2") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#moderated", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(regularToken, map[string]any{ - commandKey: joinCmd, toKey: "#moderated", + commandKey: joinCmd, toKey: channel, }) // Set +m. tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#moderated", + toKey: channel, bodyKey: []string{"+m"}, }) @@ -3692,7 +3752,7 @@ func TestModeratedChannelBlocksNonVoiced(t *testing.T) { // Regular user tries to send — should be blocked. tserver.sendCommand(regularToken, map[string]any{ commandKey: privmsgCmd, - toKey: "#moderated", + toKey: channel, bodyKey: []string{"blocked message"}, }) @@ -3710,21 +3770,23 @@ func TestModeratedChannelBlocksNonVoiced(t *testing.T) { // TestModeratedChannelAllowsOp tests that operators can // send in +m channels. func TestModeratedChannelAllowsOp(t *testing.T) { + const channel = "#modop" + tserver := newTestServer(t) opToken := tserver.createSession("modop2") observerToken := tserver.createSession("observer2") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#modop", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(observerToken, map[string]any{ - commandKey: joinCmd, toKey: "#modop", + commandKey: joinCmd, toKey: channel, }) // Set +m. tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#modop", + toKey: channel, bodyKey: []string{"+m"}, }) @@ -3733,7 +3795,7 @@ func TestModeratedChannelAllowsOp(t *testing.T) { // Op sends — should work. status, result := tserver.sendCommand(opToken, map[string]any{ commandKey: privmsgCmd, - toKey: "#modop", + toKey: channel, bodyKey: []string{"op message"}, }) if status != http.StatusOK { @@ -3755,26 +3817,28 @@ func TestModeratedChannelAllowsOp(t *testing.T) { // TestModeratedChannelAllowsVoiced tests that voiced // users can send in +m channels. func TestModeratedChannelAllowsVoiced(t *testing.T) { + const channel = "#modvoice" + tserver := newTestServer(t) opToken := tserver.createSession("modop3") voicedToken := tserver.createSession("modvoiced") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#modvoice", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(voicedToken, map[string]any{ - commandKey: joinCmd, toKey: "#modvoice", + commandKey: joinCmd, toKey: channel, }) // Set +m and +v on voiced user. tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#modvoice", + toKey: channel, bodyKey: []string{"+m"}, }) tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#modvoice", + toKey: channel, bodyKey: []string{"+v", "modvoiced"}, }) @@ -3784,7 +3848,7 @@ func TestModeratedChannelAllowsVoiced(t *testing.T) { status, result := tserver.sendCommand( voicedToken, map[string]any{ commandKey: privmsgCmd, - toKey: "#modvoice", + toKey: channel, bodyKey: []string{"voiced message"}, }, ) @@ -3849,23 +3913,25 @@ func TestTopicLockDefaultOn(t *testing.T) { // TestTopicLockEnforced verifies non-operators cannot // change topic when +t is active. func TestTopicLockEnforced(t *testing.T) { + const channel = "#tlock" + tserver := newTestServer(t) opToken := tserver.createSession("topicop") regularToken := tserver.createSession("topicuser") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#tlock", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(regularToken, map[string]any{ - commandKey: joinCmd, toKey: "#tlock", + commandKey: joinCmd, toKey: channel, }) // +t is on by default. Non-op tries to set topic. _, lastID := tserver.pollMessages(regularToken, 0) tserver.sendCommand(regularToken, map[string]any{ - commandKey: "TOPIC", - toKey: "#tlock", + commandKey: irc.CmdTopic, + toKey: channel, bodyKey: []string{"unauthorized topic"}, }) @@ -3893,7 +3959,7 @@ func TestTopicLockOpCanChange(t *testing.T) { // Op sets topic — should succeed (creator has +o). status, result := tserver.sendCommand( opToken, map[string]any{ - commandKey: "TOPIC", + commandKey: irc.CmdTopic, toKey: "#tlock2", bodyKey: []string{"op topic"}, }, @@ -3915,29 +3981,31 @@ func TestTopicLockOpCanChange(t *testing.T) { // TestTopicLockDisabled verifies that -t allows anyone // to change topic. func TestTopicLockDisabled(t *testing.T) { + const channel = "#tloff" + tserver := newTestServer(t) opToken := tserver.createSession("tloff_op") regularToken := tserver.createSession("tloff_user") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#tloff", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(regularToken, map[string]any{ - commandKey: joinCmd, toKey: "#tloff", + commandKey: joinCmd, toKey: channel, }) // Disable +t. tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#tloff", + toKey: channel, bodyKey: []string{"-t"}, }) // Now regular user sets topic — should succeed. status, result := tserver.sendCommand( regularToken, map[string]any{ - commandKey: "TOPIC", - toKey: "#tloff", + commandKey: irc.CmdTopic, + toKey: channel, bodyKey: []string{"user topic"}, }, ) @@ -3958,15 +4026,17 @@ func TestTopicLockDisabled(t *testing.T) { // TestKickByOperator tests that an operator can kick // a user. func TestKickByOperator(t *testing.T) { + const channel = "#kicktest" + tserver := newTestServer(t) opToken := tserver.createSession("kicker") targetToken := tserver.createSession("kicked") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#kicktest", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(targetToken, map[string]any{ - commandKey: joinCmd, toKey: "#kicktest", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(targetToken, 0) @@ -3974,7 +4044,7 @@ func TestKickByOperator(t *testing.T) { // Kick the target. status, _ := tserver.sendCommand(opToken, map[string]any{ commandKey: kickCmd, - toKey: "#kicktest", + toKey: channel, bodyKey: []string{"kicked", "misbehaving"}, }) if status != http.StatusOK { @@ -3997,7 +4067,7 @@ func TestKickByOperator(t *testing.T) { tserver.sendCommand(targetToken, map[string]any{ commandKey: privmsgCmd, - toKey: "#kicktest", + toKey: channel, bodyKey: []string{"still here?"}, }) @@ -4013,19 +4083,21 @@ func TestKickByOperator(t *testing.T) { // TestKickByNonOperator tests that non-operators cannot // kick. func TestKickByNonOperator(t *testing.T) { + const channel = "#kickperm" + tserver := newTestServer(t) opToken := tserver.createSession("op_kick2") nonOpToken := tserver.createSession("nonop_kick") targetToken := tserver.createSession("target_kick") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#kickperm", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(nonOpToken, map[string]any{ - commandKey: joinCmd, toKey: "#kickperm", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(targetToken, map[string]any{ - commandKey: joinCmd, toKey: "#kickperm", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(nonOpToken, 0) @@ -4033,7 +4105,7 @@ func TestKickByNonOperator(t *testing.T) { // Non-op tries to kick. tserver.sendCommand(nonOpToken, map[string]any{ commandKey: kickCmd, - toKey: "#kickperm", + toKey: channel, bodyKey: []string{"target_kick", "nope"}, }) @@ -4081,26 +4153,28 @@ func TestKickTargetNotInChannel(t *testing.T) { // TestKickBroadcastToChannel verifies the KICK message // is broadcast to all channel members. func TestKickBroadcastToChannel(t *testing.T) { + const channel = "#kickbc" + tserver := newTestServer(t) opToken := tserver.createSession("op_kb") targetToken := tserver.createSession("target_kb") observerToken := tserver.createSession("obs_kb") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#kickbc", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(targetToken, map[string]any{ - commandKey: joinCmd, toKey: "#kickbc", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(observerToken, map[string]any{ - commandKey: joinCmd, toKey: "#kickbc", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(observerToken, 0) tserver.sendCommand(opToken, map[string]any{ commandKey: kickCmd, - toKey: "#kickbc", + toKey: channel, bodyKey: []string{"target_kb", "reason"}, }) @@ -4130,7 +4204,7 @@ func TestNoticeNoAwayReply(t *testing.T) { // Send NOTICE — should NOT trigger RPL_AWAY. tserver.sendCommand(senderToken, map[string]any{ - commandKey: "NOTICE", + commandKey: irc.CmdNotice, toKey: "noticeaway", bodyKey: []string{"notice message"}, }) @@ -4182,25 +4256,27 @@ func TestPrivmsgTriggersAway(t *testing.T) { // TestNoticeSkipsHashcash verifies NOTICE bypasses // hashcash on +H channels. func TestNoticeSkipsHashcash(t *testing.T) { + const channel = "#hcnotice" + tserver := newTestServer(t) opToken := tserver.createSession("hcnotice_op") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#hcnotice", + commandKey: joinCmd, toKey: channel, }) // Set hashcash requirement. tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#hcnotice", + toKey: channel, bodyKey: []string{"+H", "2"}, }) // Send NOTICE without hashcash — should succeed. status, result := tserver.sendCommand( opToken, map[string]any{ - commandKey: "NOTICE", - toKey: "#hcnotice", + commandKey: irc.CmdNotice, + toKey: channel, bodyKey: []string{"server notice"}, }, ) @@ -4218,28 +4294,30 @@ func TestNoticeSkipsHashcash(t *testing.T) { // TestModeratedNoticeBlocked verifies +m blocks NOTICE // from non-voiced/non-op users too. func TestModeratedNoticeBlocked(t *testing.T) { + const channel = "#modnotice" + tserver := newTestServer(t) opToken := tserver.createSession("modnotop") regularToken := tserver.createSession("modnotice") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#modnotice", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(regularToken, map[string]any{ - commandKey: joinCmd, toKey: "#modnotice", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#modnotice", + toKey: channel, bodyKey: []string{"+m"}, }) _, lastID := tserver.pollMessages(regularToken, 0) tserver.sendCommand(regularToken, map[string]any{ - commandKey: "NOTICE", - toKey: "#modnotice", + commandKey: irc.CmdNotice, + toKey: channel, bodyKey: []string{"blocked notice"}, }) @@ -4256,22 +4334,24 @@ func TestModeratedNoticeBlocked(t *testing.T) { // TestNonOpCannotSetModerated verifies non-operators // cannot set +m. func TestNonOpCannotSetModerated(t *testing.T) { + const channel = "#modperm" + tserver := newTestServer(t) opToken := tserver.createSession("setmod_op") regularToken := tserver.createSession("setmod_reg") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#modperm", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(regularToken, map[string]any{ - commandKey: joinCmd, toKey: "#modperm", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(regularToken, 0) tserver.sendCommand(regularToken, map[string]any{ commandKey: modeCmd, - toKey: "#modperm", + toKey: channel, bodyKey: []string{"+m"}, }) @@ -4301,17 +4381,7 @@ func TestISupportPrefix(t *testing.T) { params := getNumericParams(isupportMsg) - found := false - - for _, param := range params { - if param == "PREFIX=(ov)@+" { - found = true - - break - } - } - - if !found { + if !slices.Contains(params, "PREFIX=(ov)@+") { t.Fatalf( "expected PREFIX=(ov)@+ in ISUPPORT, "+ "got %v", @@ -4323,23 +4393,25 @@ func TestISupportPrefix(t *testing.T) { // TestModeQueryShowsModerated verifies MODE query shows // +m when set. func TestModeQueryShowsModerated(t *testing.T) { + const channel = "#mqtest" + tserver := newTestServer(t) token := tserver.createSession("mquery") tserver.sendCommand(token, map[string]any{ - commandKey: joinCmd, toKey: "#mqtest", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(token, map[string]any{ commandKey: modeCmd, - toKey: "#mqtest", + toKey: channel, bodyKey: []string{"+m"}, }) _, lastID := tserver.pollMessages(token, 0) tserver.sendCommand(token, map[string]any{ - commandKey: modeCmd, toKey: "#mqtest", + commandKey: modeCmd, toKey: channel, }) msgs, _ := tserver.pollMessages(token, lastID) @@ -4362,15 +4434,17 @@ func TestModeQueryShowsModerated(t *testing.T) { // TestKickDefaultReason verifies KICK uses kicker's // nick as default reason. func TestKickDefaultReason(t *testing.T) { + const channel = "#kickdef" + tserver := newTestServer(t) opToken := tserver.createSession("kickdefop") targetToken := tserver.createSession("kickdeftg") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#kickdef", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(targetToken, map[string]any{ - commandKey: joinCmd, toKey: "#kickdef", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(targetToken, 0) @@ -4378,7 +4452,7 @@ func TestKickDefaultReason(t *testing.T) { // Kick with only nick, no reason. tserver.sendCommand(opToken, map[string]any{ commandKey: kickCmd, - toKey: "#kickdef", + toKey: channel, bodyKey: []string{"kickdeftg"}, }) @@ -4401,17 +4475,19 @@ const ( // TestBanAddRemoveList verifies +b add, list, and -b // remove via MODE commands. func TestBanAddRemoveList(t *testing.T) { + const channel = "#bans" + tserver := newTestServer(t) opToken := tserver.createSession("banop") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#bans", + commandKey: joinCmd, toKey: channel, }) // Add a ban. tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#bans", + toKey: channel, bodyKey: []string{"+b", "*!*@evil.com"}, }) @@ -4420,7 +4496,7 @@ func TestBanAddRemoveList(t *testing.T) { // List bans (+b with no argument). tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#bans", + toKey: channel, bodyKey: []string{"+b"}, }) @@ -4440,7 +4516,7 @@ func TestBanAddRemoveList(t *testing.T) { // Remove the ban. tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#bans", + toKey: channel, bodyKey: []string{"-b", "*!*@evil.com"}, }) @@ -4449,11 +4525,12 @@ func TestBanAddRemoveList(t *testing.T) { // List again — should be empty (just end-of-list). tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#bans", + toKey: channel, bodyKey: []string{"+b"}, }) msgs, _ = tserver.pollMessages(opToken, lastID) + banMsg = findNumericWithParams(msgs, "367") if banMsg != nil { t.Fatal("expected no 367 after ban removal") @@ -4467,24 +4544,26 @@ func TestBanAddRemoveList(t *testing.T) { // TestBanBlocksJoin verifies that a banned user cannot // join a channel. func TestBanBlocksJoin(t *testing.T) { + const channel = "#banjoin" + tserver := newTestServer(t) opToken := tserver.createSession("banop2") userToken := tserver.createSession("banned2") // Op creates channel and sets a ban. tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#banjoin", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#banjoin", + toKey: channel, bodyKey: []string{"+b", "banned2!*@*"}, }) // Banned user tries to join. _, lastID := tserver.pollMessages(userToken, 0) tserver.sendCommand(userToken, map[string]any{ - commandKey: joinCmd, toKey: "#banjoin", + commandKey: joinCmd, toKey: channel, }) msgs, _ := tserver.pollMessages(userToken, lastID) @@ -4498,22 +4577,24 @@ func TestBanBlocksJoin(t *testing.T) { // TestBanBlocksPrivmsg verifies that a banned user who // is already in a channel cannot send messages. func TestBanBlocksPrivmsg(t *testing.T) { + const channel = "#banmsg" + tserver := newTestServer(t) opToken := tserver.createSession("banmsgop") userToken := tserver.createSession("banmsgusr") // Both join. tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#banmsg", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(userToken, map[string]any{ - commandKey: joinCmd, toKey: "#banmsg", + commandKey: joinCmd, toKey: channel, }) // Op bans the user. tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#banmsg", + toKey: channel, bodyKey: []string{"+b", "banmsgusr!*@*"}, }) @@ -4521,7 +4602,7 @@ func TestBanBlocksPrivmsg(t *testing.T) { _, lastID := tserver.pollMessages(userToken, 0) tserver.sendCommand(userToken, map[string]any{ commandKey: privmsgCmd, - toKey: "#banmsg", + toKey: channel, bodyKey: []string{"hello"}, }) @@ -4536,24 +4617,26 @@ func TestBanBlocksPrivmsg(t *testing.T) { // TestInviteOnlyJoin verifies +i behavior: join rejected // without invite, accepted with invite. func TestInviteOnlyJoin(t *testing.T) { + const channel = "#invonly" + tserver := newTestServer(t) opToken := tserver.createSession("invop") userToken := tserver.createSession("invusr") // Op creates channel and sets +i. tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#invonly", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#invonly", + toKey: channel, bodyKey: []string{"+i"}, }) // User tries to join without invite. _, lastID := tserver.pollMessages(userToken, 0) tserver.sendCommand(userToken, map[string]any{ - commandKey: joinCmd, toKey: "#invonly", + commandKey: joinCmd, toKey: channel, }) msgs, _ := tserver.pollMessages(userToken, lastID) @@ -4568,12 +4651,12 @@ func TestInviteOnlyJoin(t *testing.T) { // Op invites user. tserver.sendCommand(opToken, map[string]any{ commandKey: inviteCmd, - bodyKey: []string{"invusr", "#invonly"}, + bodyKey: []string{"invusr", channel}, }) // User tries again — should succeed with invite. _, result := tserver.sendCommand(userToken, map[string]any{ - commandKey: joinCmd, toKey: "#invonly", + commandKey: joinCmd, toKey: channel, }) if result[statusKey] != joinedStatus { @@ -4587,17 +4670,19 @@ func TestInviteOnlyJoin(t *testing.T) { // TestSecretChannelHiddenFromList verifies +s hides a // channel from LIST for non-members. func TestSecretChannelHiddenFromList(t *testing.T) { + const channel = "#secret" + tserver := newTestServer(t) opToken := tserver.createSession("secop") outsiderToken := tserver.createSession("secout") // Op creates secret channel. tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#secret", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#secret", + toKey: channel, bodyKey: []string{"+s"}, }) @@ -4618,7 +4703,7 @@ func TestSecretChannelHiddenFromList(t *testing.T) { params := getNumericParams(msg) for _, p := range params { - if p == "#secret" { + if p == channel { t.Fatal("outsider should not see #secret in LIST") } } @@ -4642,7 +4727,7 @@ func TestSecretChannelHiddenFromList(t *testing.T) { params := getNumericParams(msg) for _, p := range params { - if p == "#secret" { + if p == channel { found = true } } @@ -4656,24 +4741,26 @@ func TestSecretChannelHiddenFromList(t *testing.T) { // TestChannelKeyJoin verifies +k behavior: wrong/missing // key is rejected, correct key allows join. func TestChannelKeyJoin(t *testing.T) { + const channel = "#keyed" + tserver := newTestServer(t) opToken := tserver.createSession("keyop") userToken := tserver.createSession("keyusr") // Op creates keyed channel. tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#keyed", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#keyed", + toKey: channel, bodyKey: []string{"+k", "mykey"}, }) // User tries without key. _, lastID := tserver.pollMessages(userToken, 0) tserver.sendCommand(userToken, map[string]any{ - commandKey: joinCmd, toKey: "#keyed", + commandKey: joinCmd, toKey: channel, }) msgs, _ := tserver.pollMessages(userToken, lastID) @@ -4688,7 +4775,7 @@ func TestChannelKeyJoin(t *testing.T) { // User tries with wrong key. tserver.sendCommand(userToken, map[string]any{ commandKey: joinCmd, - toKey: "#keyed", + toKey: channel, bodyKey: []string{"wrongkey"}, }) @@ -4703,7 +4790,7 @@ func TestChannelKeyJoin(t *testing.T) { // User tries with correct key. _, result := tserver.sendCommand(userToken, map[string]any{ commandKey: joinCmd, - toKey: "#keyed", + toKey: channel, bodyKey: []string{"mykey"}, }) @@ -4718,6 +4805,8 @@ func TestChannelKeyJoin(t *testing.T) { // TestUserLimitEnforcement verifies +l behavior: blocks // join when at capacity. func TestUserLimitEnforcement(t *testing.T) { + const channel = "#limited" + tserver := newTestServer(t) opToken := tserver.createSession("limop") user1Token := tserver.createSession("limusr1") @@ -4725,17 +4814,17 @@ func TestUserLimitEnforcement(t *testing.T) { // Op creates channel with limit 2. tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#limited", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#limited", + toKey: channel, bodyKey: []string{"+l", "2"}, }) // User1 joins — should succeed (2 members now: op + user1). _, result := tserver.sendCommand(user1Token, map[string]any{ - commandKey: joinCmd, toKey: "#limited", + commandKey: joinCmd, toKey: channel, }) if result[statusKey] != joinedStatus { t.Fatalf("user1 should join, got %v", result) @@ -4744,7 +4833,7 @@ func TestUserLimitEnforcement(t *testing.T) { // User2 tries to join — should fail (at limit: 2/2). _, lastID := tserver.pollMessages(user2Token, 0) tserver.sendCommand(user2Token, map[string]any{ - commandKey: joinCmd, toKey: "#limited", + commandKey: joinCmd, toKey: channel, }) msgs, _ := tserver.pollMessages(user2Token, lastID) @@ -4760,11 +4849,13 @@ func TestUserLimitEnforcement(t *testing.T) { // TestModeStringIncludesNewModes verifies that querying // channel mode returns the new modes (+i, +s, +k, +l). func TestModeStringIncludesNewModes(t *testing.T) { + const channel = "#modestr" + tserver := newTestServer(t) opToken := tserver.createSession("modestrop") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#modestr", + commandKey: joinCmd, toKey: channel, }) // Set all tier 2 modes. @@ -4773,7 +4864,7 @@ func TestModeStringIncludesNewModes(t *testing.T) { } { tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#modestr", + toKey: channel, bodyKey: modeChange, }) } @@ -4782,7 +4873,7 @@ func TestModeStringIncludesNewModes(t *testing.T) { // Query mode. tserver.sendCommand(opToken, map[string]any{ - commandKey: modeCmd, toKey: "#modestr", + commandKey: modeCmd, toKey: channel, }) msgs, _ := tserver.pollMessages(opToken, lastID) @@ -4838,15 +4929,17 @@ func TestISUPPORT(t *testing.T) { // TestNonOpCannotSetModes verifies non-operators // cannot set +i, +s, +k, +l, +b. func TestNonOpCannotSetModes(t *testing.T) { + const channel = "#noperm" + tserver := newTestServer(t) opToken := tserver.createSession("modeopx") userToken := tserver.createSession("modeusrx") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#noperm", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(userToken, map[string]any{ - commandKey: joinCmd, toKey: "#noperm", + commandKey: joinCmd, toKey: channel, }) modes := [][]string{ @@ -4858,7 +4951,7 @@ func TestNonOpCannotSetModes(t *testing.T) { _, lastID := tserver.pollMessages(userToken, 0) tserver.sendCommand(userToken, map[string]any{ commandKey: modeCmd, - toKey: "#noperm", + toKey: channel, bodyKey: modeChange, }) diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 56033be..7104c13 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -117,11 +117,11 @@ func (hdlr *Handlers) executeLogin( request, clientID, sessionID, nick, ) - hdlr.setAuthCookie(writer, request, token) + hdlr.setAuthCookie(writer, token) hdlr.respondJSON(writer, request, map[string]any{ - "id": sessionID, - "nick": nick, + "id": sessionID, + nickKey: nick, }, http.StatusOK) } @@ -177,6 +177,6 @@ func (hdlr *Handlers) handlePass( } hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 81d1acb..a150cc8 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -24,6 +24,14 @@ import ( var errUnauthorized = errors.New("unauthorized") +// Field names and values used in many JSON responses. +const ( + statusKey = "status" + statusError = "error" + errorKey = "error" + nickKey = "nick" +) + // Params defines the dependencies for creating Handlers. type Params struct { fx.In @@ -137,7 +145,7 @@ func (hdlr *Handlers) respondError( ) { hdlr.respondJSON( writer, request, - map[string]string{"error": msg}, + map[string]string{errorKey: msg}, status, ) } diff --git a/internal/hashcash/hashcash.go b/internal/hashcash/hashcash.go index 345c20b..2c00725 100644 --- a/internal/hashcash/hashcash.go +++ b/internal/hashcash/hashcash.go @@ -100,9 +100,10 @@ func (v *Validator) Validate( dateStr := parts[2] resource := parts[3] - if err := v.validateHeader( + err := v.validateHeader( version, bitsStr, resource, requiredBits, - ); err != nil { + ) + if err != nil { return err } @@ -111,13 +112,13 @@ func (v *Validator) Validate( return err } - if err := validateTime(stampTime); err != nil { + err = validateTime(stampTime) + if err != nil { return err } - if err := validateProof( - stamp, requiredBits, - ); err != nil { + err = validateProof(stamp, requiredBits) + if err != nil { return err } diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index a336ec1..e6c3356 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -159,9 +159,7 @@ func (c *Conn) handleJoin( return } - channels := strings.Split(msg.Params[0], ",") - - for _, chanName := range channels { + for chanName := range strings.SplitSeq(msg.Params[0], ",") { chanName = strings.TrimSpace(chanName) if !strings.HasPrefix(chanName, "#") { @@ -305,9 +303,7 @@ func (c *Conn) handlePart( reason = msg.Params[1] } - channels := strings.Split(msg.Params[0], ",") - - for _, ch := range channels { + for ch := range strings.SplitSeq(msg.Params[0], ",") { ch = strings.TrimSpace(ch) c.partChannel(ctx, ch, reason) } @@ -619,8 +615,8 @@ func (c *Conn) applyChannelModes( ) { adding := true argIdx := 0 - applied := "" - appliedArgs := "" + + var applied, appliedArgs strings.Builder for _, modeChar := range modeStr { var res modeResult @@ -672,16 +668,13 @@ func (c *Conn) applyChannelModes( argIdx += res.consumed if !res.skip { - applied += res.applied - appliedArgs += res.appliedArgs + applied.WriteString(res.applied) + appliedArgs.WriteString(res.appliedArgs) } } - if applied != "" { - modeReply := applied - if appliedArgs != "" { - modeReply += appliedArgs - } + if applied.Len() > 0 { + modeReply := applied.String() + appliedArgs.String() c.send(FormatMessage( c.hostmask(), "MODE", channel, modeReply, diff --git a/internal/ircserver/conn.go b/internal/ircserver/conn.go index bd4fbb7..fdd6b35 100644 --- a/internal/ircserver/conn.go +++ b/internal/ircserver/conn.go @@ -62,7 +62,6 @@ type Conn struct { lastQueueID int64 closed bool - cancel context.CancelFunc } func newConn( @@ -151,8 +150,10 @@ func resolveHost(ctx context.Context, addr string) string { } // serve is the main loop for a single IRC client connection. +// Cancelling ctx when it returns stops the relay goroutine. func (c *Conn) serve(ctx context.Context) { - ctx, c.cancel = context.WithCancel(ctx) + ctx, cancel := context.WithCancel(ctx) + defer cancel() defer c.cleanup(ctx) scanner := bufio.NewScanner(c.conn) @@ -481,7 +482,7 @@ func (c *Conn) deliverMOTD() { "- %s Message of the Day -", c.serverSfx, )) - for _, line := range strings.Split(motd, "\n") { + for line := range strings.SplitSeq(motd, "\n") { c.sendNumeric(irc.RplMotd, "- "+line) } diff --git a/internal/ircserver/integration_test.go b/internal/ircserver/integration_test.go index d6da16b..57cd413 100644 --- a/internal/ircserver/integration_test.go +++ b/internal/ircserver/integration_test.go @@ -282,6 +282,7 @@ func TestIntegrationTwoClients(t *testing.T) { // Both nicks should appear in the name list. foundBothNames := false + for _, line := range aliceNames { if strings.Contains(line, " 353 ") && strings.Contains(line, "alice") && @@ -671,6 +672,7 @@ func TestIntegrationTwoClients(t *testing.T) { }) foundPartErr := false + for _, line := range bobPartFail { if strings.Contains(line, " 403 ") || strings.Contains(line, " 442 ") { @@ -833,6 +835,7 @@ func TestIntegrationModeModerated(t *testing.T) { }) foundModErr := false + for _, line := range bobLines { if strings.Contains(line, " 404 ") || strings.Contains(line, " 482 ") { @@ -859,6 +862,7 @@ func TestIntegrationModeModerated(t *testing.T) { }) bob.send("PRIVMSG #modtest :voiced message") + aliceLines := alice.readUntil(func(l string) bool { return strings.Contains(l, "voiced message") }) diff --git a/internal/ircserver/parser_test.go b/internal/ircserver/parser_test.go index 23fdd5f..936e811 100644 --- a/internal/ircserver/parser_test.go +++ b/internal/ircserver/parser_test.go @@ -4,12 +4,18 @@ import ( "testing" "sneak.berlin/go/neoirc/internal/ircserver" + "sneak.berlin/go/neoirc/pkg/irc" ) //nolint:funlen // table-driven test func TestParseMessage(t *testing.T) { t.Parallel() + const ( + nick = "alice" + channel = "#general" + ) + tests := []struct { name string input string @@ -24,10 +30,10 @@ func TestParseMessage(t *testing.T) { }, { name: "simple command", - input: "PING", + input: irc.CmdPing, want: &ircserver.Message{ Prefix: "", - Command: "PING", + Command: irc.CmdPing, Params: nil, }, wantNil: false, @@ -38,7 +44,7 @@ func TestParseMessage(t *testing.T) { want: &ircserver.Message{ Prefix: "", Command: "NICK", - Params: []string{"alice"}, + Params: []string{nick}, }, wantNil: false, }, @@ -57,8 +63,8 @@ func TestParseMessage(t *testing.T) { input: "PRIVMSG #general :hello world", want: &ircserver.Message{ Prefix: "", - Command: "PRIVMSG", - Params: []string{"#general", "hello world"}, + Command: irc.CmdPrivmsg, + Params: []string{channel, "hello world"}, }, wantNil: false, }, @@ -68,7 +74,7 @@ func TestParseMessage(t *testing.T) { want: &ircserver.Message{ Prefix: "server.example.com", Command: "001", - Params: []string{"alice", "Welcome to IRC"}, + Params: []string{nick, "Welcome to IRC"}, }, wantNil: false, }, @@ -79,7 +85,7 @@ func TestParseMessage(t *testing.T) { Prefix: "", Command: "USER", Params: []string{ - "alice", "0", "*", "Alice Smith", + nick, "0", "*", "Alice Smith", }, }, wantNil: false, @@ -90,7 +96,7 @@ func TestParseMessage(t *testing.T) { want: &ircserver.Message{ Prefix: "", Command: "JOIN", - Params: []string{"#general"}, + Params: []string{channel}, }, wantNil: false, }, @@ -99,17 +105,17 @@ func TestParseMessage(t *testing.T) { input: "QUIT :leaving now", want: &ircserver.Message{ Prefix: "", - Command: "QUIT", + Command: irc.CmdQuit, Params: []string{"leaving now"}, }, wantNil: false, }, { name: "quit without reason", - input: "QUIT", + input: irc.CmdQuit, want: &ircserver.Message{ Prefix: "", - Command: "QUIT", + Command: irc.CmdQuit, Params: nil, }, wantNil: false, @@ -120,7 +126,7 @@ func TestParseMessage(t *testing.T) { want: &ircserver.Message{ Prefix: "", Command: "MODE", - Params: []string{"#general"}, + Params: []string{channel}, }, wantNil: false, }, @@ -131,7 +137,7 @@ func TestParseMessage(t *testing.T) { Prefix: "", Command: "KICK", Params: []string{ - "#general", "bob", "misbehaving", + channel, "bob", "misbehaving", }, }, wantNil: false, @@ -141,8 +147,8 @@ func TestParseMessage(t *testing.T) { input: "PRIVMSG #general :", want: &ircserver.Message{ Prefix: "", - Command: "PRIVMSG", - Params: []string{"#general", ""}, + Command: irc.CmdPrivmsg, + Params: []string{channel, ""}, }, wantNil: false, }, @@ -161,7 +167,7 @@ func TestParseMessage(t *testing.T) { input: "PING :irc.example.com", want: &ircserver.Message{ Prefix: "", - Command: "PING", + Command: irc.CmdPing, Params: []string{"irc.example.com"}, }, wantNil: false, @@ -173,7 +179,7 @@ func TestParseMessage(t *testing.T) { Prefix: "", Command: "TOPIC", Params: []string{ - "#general", + channel, "Welcome to the channel!", }, }, @@ -237,6 +243,12 @@ func TestParseMessage(t *testing.T) { func TestFormatMessage(t *testing.T) { t.Parallel() + const ( + nick = "alice" + channel = "#general" + serverName = "server" + ) + tests := []struct { name string prefix string @@ -247,35 +259,35 @@ func TestFormatMessage(t *testing.T) { { name: "simple command", prefix: "", - command: "PING", + command: irc.CmdPing, params: nil, - want: "PING", + want: irc.CmdPing, }, { name: "with prefix", - prefix: "server", + prefix: serverName, command: "PONG", - params: []string{"server"}, + params: []string{serverName}, want: ":server PONG server", }, { name: "privmsg with trailing", prefix: "alice!alice@host", - command: "PRIVMSG", - params: []string{"#general", "hello world"}, + command: irc.CmdPrivmsg, + params: []string{channel, "hello world"}, want: ":alice!alice@host PRIVMSG #general :hello world", }, { name: "numeric reply", - prefix: "server", + prefix: serverName, command: "001", - params: []string{"alice", "Welcome to IRC"}, + params: []string{nick, "Welcome to IRC"}, want: ":server 001 alice :Welcome to IRC", }, { name: "empty trailing", - prefix: "server", - command: "PRIVMSG", + prefix: serverName, + command: irc.CmdPrivmsg, params: []string{"#chan", ""}, want: ":server PRIVMSG #chan :", }, @@ -302,7 +314,7 @@ func TestParseFormatRoundTrip(t *testing.T) { // parameter either contains a space (gets ':' prefix // on format) or is a non-trailing single token. lines := []string{ - "PING", + irc.CmdPing, "NICK alice", "PRIVMSG #general :hello world", "JOIN #general", diff --git a/internal/ircserver/server.go b/internal/ircserver/server.go index 5aeeab0..e3b6d2e 100644 --- a/internal/ircserver/server.go +++ b/internal/ircserver/server.go @@ -81,22 +81,24 @@ func New( // start begins listening for TCP connections. // //nolint:contextcheck // long-lived server ctx, not the short Fx one -func (s *Server) start(_ context.Context, addr string) error { - ln, err := net.Listen("tcp", addr) +func (s *Server) start(ctx context.Context, addr string) error { + var listenConfig net.ListenConfig + + ln, err := listenConfig.Listen(ctx, "tcp", addr) if err != nil { return fmt.Errorf("irc listen: %w", err) } s.listener = ln - ctx, cancel := context.WithCancel(context.Background()) + serverCtx, cancel := context.WithCancel(context.Background()) s.cancel = cancel s.log.Info( "irc server listening", "addr", addr, ) - go s.acceptLoop(ctx) + go s.acceptLoop(serverCtx) return nil } diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index ead0963..0ea6f4e 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -7,6 +7,7 @@ import ( "log/slog" "net" "os" + "runtime" "strings" "testing" "time" @@ -71,7 +72,9 @@ func newTestEnv(t *testing.T) *testEnv { MOTD: "Welcome to test IRC", } - listener, err := net.Listen("tcp", "127.0.0.1:0") + var listenConfig net.ListenConfig + + listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } @@ -116,10 +119,12 @@ func newTestEnv(t *testing.T) *testEnv { func (env *testEnv) dial(t *testing.T) *testClient { t.Helper() - conn, err := net.DialTimeout( + dialer := net.Dialer{Timeout: testTimeout} + + conn, err := dialer.DialContext( + t.Context(), "tcp", env.srv.Listener().Addr().String(), - testTimeout, ) if err != nil { t.Fatalf("dial: %v", err) @@ -323,6 +328,7 @@ func TestPrivmsgBetweenClients(t *testing.T) { bob.joinAndDrain("#chat") alice.send("PRIVMSG #chat :hello bob!") + lines := bob.sendAndExpect("PING :sync", "hello bob!") assertContains(t, lines, "hello bob!", "channel PRIVMSG") } @@ -603,6 +609,56 @@ func TestNamesNonExistentChannel(t *testing.T) { ) } +// TestRelayStopsWhenConnectionCloses checks that closing a +// registered client's connection stops the goroutine that +// relays its messages. +// +//nolint:paralleltest // counts every goroutine in the process +func TestRelayStopsWhenConnectionCloses(t *testing.T) { + env := newTestEnv(t) + client := env.dial(t) + + client.register("relaystop") + waitForRelayGoroutines(t, 1) + + err := client.conn.Close() + if err != nil { + t.Fatalf("close: %v", err) + } + + waitForRelayGoroutines(t, 0) +} + +const ( + stackDumpSize = 1 << 20 + relayCheckStep = 10 * time.Millisecond +) + +// waitForRelayGoroutines waits until exactly want goroutines +// are running the relay loop, and fails the test if that does +// not happen within testTimeout. +func waitForRelayGoroutines(t *testing.T, want int) { + t.Helper() + + deadline := time.Now().Add(testTimeout) + stacks := make([]byte, stackDumpSize) + + for { + n := runtime.Stack(stacks, true) + got := strings.Count(string(stacks[:n]), "(*Conn).relayMessages(") + + if got == want { + return + } + + if time.Now().After(deadline) { + t.Fatalf("relay goroutines: got %d, want %d", got, want) + } + + time.Sleep(relayCheckStep) + } +} + func BenchmarkParseMessage(b *testing.B) { line := ":nick!user@host PRIVMSG #channel :Hello, world!" diff --git a/internal/server/server.go b/internal/server/server.go index 1006342..d19f869 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -78,7 +78,9 @@ func New( srv.enableSentry() srv.SetupRoutes() - go srv.serve() //nolint:contextcheck + // The start hook's context ends when the hook + // returns; serving must outlive it. + go srv.serve() //nolint:contextcheck,gosec // G118 return nil }, diff --git a/internal/service/service.go b/internal/service/service.go index bc6b09c..690ce08 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -19,6 +19,12 @@ import ( "sneak.berlin/go/neoirc/pkg/irc" ) +// Error texts that several commands reply with. +const ( + msgNoSuchChannel = "No such channel" + msgNotChannelOp = "You're not channel operator" +) + // Params defines the dependencies for creating a Service. type Params struct { fx.In @@ -142,7 +148,7 @@ func (s *Service) SendChannelMessage( return 0, "", &IRCError{ irc.ErrNoSuchChannel, []string{channel}, - "No such channel", + msgNoSuchChannel, } } @@ -256,10 +262,11 @@ func (s *Service) JoinChannel( isCreator := countErr == nil && memberCount == 0 if !isCreator { - if joinErr := checkJoinRestrictions( + joinErr := checkJoinRestrictions( ctx, s.db, chID, sessionID, channel, suppliedKey, memberCount, - ); joinErr != nil { + ) + if joinErr != nil { return nil, joinErr } } @@ -306,7 +313,7 @@ func (s *Service) PartChannel( return &IRCError{ irc.ErrNoSuchChannel, []string{channel}, - "No such channel", + msgNoSuchChannel, } } @@ -348,7 +355,7 @@ func (s *Service) SetTopic( return &IRCError{ irc.ErrNoSuchChannel, []string{channel}, - "No such channel", + msgNoSuchChannel, } } @@ -372,14 +379,13 @@ func (s *Service) SetTopic( return &IRCError{ irc.ErrChanOpPrivsNeeded, []string{channel}, - "You're not channel operator", + msgNotChannelOp, } } } - if setErr := s.db.SetTopic( - ctx, channel, topic, - ); setErr != nil { + setErr := s.db.SetTopic(ctx, channel, topic) + if setErr != nil { return fmt.Errorf("set topic: %w", setErr) } @@ -409,7 +415,7 @@ func (s *Service) KickUser( return &IRCError{ irc.ErrNoSuchChannel, []string{channel}, - "No such channel", + msgNoSuchChannel, } } @@ -420,7 +426,7 @@ func (s *Service) KickUser( return &IRCError{ irc.ErrChanOpPrivsNeeded, []string{channel}, - "You're not channel operator", + msgNotChannelOp, } } @@ -609,7 +615,7 @@ func (s *Service) ValidateChannelOp( return 0, &IRCError{ irc.ErrNoSuchChannel, []string{channel}, - "No such channel", + msgNoSuchChannel, } } @@ -620,7 +626,7 @@ func (s *Service) ValidateChannelOp( return 0, &IRCError{ irc.ErrChanOpPrivsNeeded, []string{channel}, - "You're not channel operator", + msgNotChannelOp, } } @@ -682,33 +688,28 @@ func (s *Service) SetChannelFlag( ) error { switch flag { case 'm': - if err := s.db.SetChannelModerated( - ctx, chID, setting, - ); err != nil { + err := s.db.SetChannelModerated(ctx, chID, setting) + if err != nil { return fmt.Errorf("set moderated: %w", err) } case 't': - if err := s.db.SetChannelTopicLocked( - ctx, chID, setting, - ); err != nil { + err := s.db.SetChannelTopicLocked(ctx, chID, setting) + if err != nil { return fmt.Errorf("set topic locked: %w", err) } case 'i': - if err := s.db.SetChannelInviteOnly( - ctx, chID, setting, - ); err != nil { + err := s.db.SetChannelInviteOnly(ctx, chID, setting) + if err != nil { return fmt.Errorf("set invite only: %w", err) } case 's': - if err := s.db.SetChannelSecret( - ctx, chID, setting, - ); err != nil { + err := s.db.SetChannelSecret(ctx, chID, setting) + if err != nil { return fmt.Errorf("set secret: %w", err) } case 'n': - if err := s.db.SetChannelNoExternal( - ctx, chID, setting, - ); err != nil { + err := s.db.SetChannelNoExternal(ctx, chID, setting) + if err != nil { return fmt.Errorf( "set no external: %w", err, ) diff --git a/package.json b/package.json new file mode 100644 index 0000000..514f67b --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "devDependencies": { + "prettier": "3.8.1" + } +} diff --git a/pkg/irc/numerics.go b/pkg/irc/numerics.go index b7bba22..9146608 100644 --- a/pkg/irc/numerics.go +++ b/pkg/irc/numerics.go @@ -68,6 +68,7 @@ const ( // Command responses (200-399). const ( // RFC 2812 trace/stats/links replies (200-219). + RplTraceLink IRCMessageType = 200 RplTraceConnecting IRCMessageType = 201 RplTraceHandshake IRCMessageType = 202 @@ -224,7 +225,7 @@ const ( // names maps numeric codes to their standard IRC names. // -//nolint:gochecknoglobals +//nolint:gochecknoglobals,gosec // G101: IRC numeric names, not credentials var names = map[IRCMessageType]string{ RplWelcome: "RPL_WELCOME", RplYourHost: "RPL_YOURHOST", diff --git a/schema/README.md b/schema/README.md index 6aaa5fb..4a91ae6 100644 --- a/schema/README.md +++ b/schema/README.md @@ -22,19 +22,20 @@ Structured: {"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64..."} Common fields (see `message.json` for full schema): -| Field | Type | Description | -|-----------|----------------|------------------------------------------------------| -| `id` | string (uuid) | Server-assigned message UUID | -| `command` | string | IRC command or 3-digit numeric code | -| `from` | string | Source nick or server name (IRC prefix) | -| `to` | string | Target: #channel or nick | -| `params` | string[] | Middle parameters (mainly for numerics) | -| `body` | array \| object | Structured body — never a raw string (see below) | -| `ts` | string | ISO 8601 timestamp (server-assigned, not in raw IRC) | -| `meta` | object | Extensible metadata (signatures, hashes, etc.) | +| Field | Type | Description | +| --------- | --------------- | ---------------------------------------------------- | +| `id` | string (uuid) | Server-assigned message UUID | +| `command` | string | IRC command or 3-digit numeric code | +| `from` | string | Source nick or server name (IRC prefix) | +| `to` | string | Target: #channel or nick | +| `params` | string[] | Middle parameters (mainly for numerics) | +| `body` | array \| object | Structured body — never a raw string (see below) | +| `ts` | string | ISO 8601 timestamp (server-assigned, not in raw IRC) | +| `meta` | object | Extensible metadata (signatures, hashes, etc.) | **Structured bodies:** `body` is always an array of strings (for text) or an object (for structured data like PUBKEY). Never a raw string. This enables: + - Multiline messages without escape sequences - Deterministic canonicalization via RFC 8785 JCS for signing - Structured data where needed @@ -43,20 +44,20 @@ object (for structured data like PUBKEY). Never a raw string. This enables: IRC commands used for client↔server and server↔server communication. -| Command | File | RFC | Description | -|-----------|---------------------------|-----------|--------------------------------| -| `PRIVMSG` | `commands/PRIVMSG.json` | 1459 §4.4.1 | Message to channel or user | -| `NOTICE` | `commands/NOTICE.json` | 1459 §4.4.2 | Notice (no auto-reply) | -| `JOIN` | `commands/JOIN.json` | 1459 §4.2.1 | Join a channel | -| `PART` | `commands/PART.json` | 1459 §4.2.2 | Leave a channel | -| `QUIT` | `commands/QUIT.json` | 1459 §4.1.6 | User disconnected | -| `NICK` | `commands/NICK.json` | 1459 §4.1.2 | Change nickname | -| `TOPIC` | `commands/TOPIC.json` | 1459 §4.2.4 | Get/set channel topic | -| `MODE` | `commands/MODE.json` | 1459 §4.2.3 | Set channel/user modes | -| `KICK` | `commands/KICK.json` | 1459 §4.2.8 | Kick user from channel | -| `PING` | `commands/PING.json` | 1459 §4.6.2 | Keepalive | -| `PONG` | `commands/PONG.json` | 1459 §4.6.3 | Keepalive response | -| `PUBKEY` | `commands/PUBKEY.json` | (extension) | Announce/relay signing key | +| Command | File | RFC | Description | +| --------- | ----------------------- | ----------- | -------------------------- | +| `PRIVMSG` | `commands/PRIVMSG.json` | 1459 §4.4.1 | Message to channel or user | +| `NOTICE` | `commands/NOTICE.json` | 1459 §4.4.2 | Notice (no auto-reply) | +| `JOIN` | `commands/JOIN.json` | 1459 §4.2.1 | Join a channel | +| `PART` | `commands/PART.json` | 1459 §4.2.2 | Leave a channel | +| `QUIT` | `commands/QUIT.json` | 1459 §4.1.6 | User disconnected | +| `NICK` | `commands/NICK.json` | 1459 §4.1.2 | Change nickname | +| `TOPIC` | `commands/TOPIC.json` | 1459 §4.2.4 | Get/set channel topic | +| `MODE` | `commands/MODE.json` | 1459 §4.2.3 | Set channel/user modes | +| `KICK` | `commands/KICK.json` | 1459 §4.2.8 | Kick user from channel | +| `PING` | `commands/PING.json` | 1459 §4.6.2 | Keepalive | +| `PONG` | `commands/PONG.json` | 1459 §4.6.3 | Keepalive response | +| `PUBKEY` | `commands/PUBKEY.json` | (extension) | Announce/relay signing key | ## Numeric Replies @@ -64,30 +65,30 @@ Three-digit codes for server responses, per IRC convention. ### Success / Informational (0xx–3xx) -| Code | Name | File | Description | -|-------|-------------------|-----------------------|--------------------------------| -| `001` | RPL_WELCOME | `numerics/001.json` | Welcome after session creation | -| `002` | RPL_YOURHOST | `numerics/002.json` | Server host info | -| `003` | RPL_CREATED | `numerics/003.json` | Server creation date | -| `004` | RPL_MYINFO | `numerics/004.json` | Server info and modes | -| `322` | RPL_LIST | `numerics/322.json` | Channel list entry | -| `323` | RPL_LISTEND | `numerics/323.json` | End of channel list | -| `332` | RPL_TOPIC | `numerics/332.json` | Channel topic | -| `353` | RPL_NAMREPLY | `numerics/353.json` | Channel member list | -| `366` | RPL_ENDOFNAMES | `numerics/366.json` | End of NAMES list | -| `372` | RPL_MOTD | `numerics/372.json` | MOTD line | -| `375` | RPL_MOTDSTART | `numerics/375.json` | Start of MOTD | -| `376` | RPL_ENDOFMOTD | `numerics/376.json` | End of MOTD | +| Code | Name | File | Description | +| ----- | -------------- | ------------------- | ------------------------------ | +| `001` | RPL_WELCOME | `numerics/001.json` | Welcome after session creation | +| `002` | RPL_YOURHOST | `numerics/002.json` | Server host info | +| `003` | RPL_CREATED | `numerics/003.json` | Server creation date | +| `004` | RPL_MYINFO | `numerics/004.json` | Server info and modes | +| `322` | RPL_LIST | `numerics/322.json` | Channel list entry | +| `323` | RPL_LISTEND | `numerics/323.json` | End of channel list | +| `332` | RPL_TOPIC | `numerics/332.json` | Channel topic | +| `353` | RPL_NAMREPLY | `numerics/353.json` | Channel member list | +| `366` | RPL_ENDOFNAMES | `numerics/366.json` | End of NAMES list | +| `372` | RPL_MOTD | `numerics/372.json` | MOTD line | +| `375` | RPL_MOTDSTART | `numerics/375.json` | Start of MOTD | +| `376` | RPL_ENDOFMOTD | `numerics/376.json` | End of MOTD | ### Errors (4xx) -| Code | Name | File | Description | -|-------|----------------------|-----------------------|--------------------------------| -| `401` | ERR_NOSUCHNICK | `numerics/401.json` | No such nick/channel | -| `403` | ERR_NOSUCHCHANNEL | `numerics/403.json` | No such channel | -| `433` | ERR_NICKNAMEINUSE | `numerics/433.json` | Nickname already in use | -| `442` | ERR_NOTONCHANNEL | `numerics/442.json` | Not on that channel | -| `482` | ERR_CHANOPRIVSNEEDED | `numerics/482.json` | Not channel operator | +| Code | Name | File | Description | +| ----- | -------------------- | ------------------- | ----------------------- | +| `401` | ERR_NOSUCHNICK | `numerics/401.json` | No such nick/channel | +| `403` | ERR_NOSUCHCHANNEL | `numerics/403.json` | No such channel | +| `433` | ERR_NICKNAMEINUSE | `numerics/433.json` | Nickname already in use | +| `442` | ERR_NOTONCHANNEL | `numerics/442.json` | Not on that channel | +| `482` | ERR_CHANOPRIVSNEEDED | `numerics/482.json` | Not channel operator | ## Federation (S2S) diff --git a/script/bootstrap b/script/bootstrap new file mode 100755 index 0000000..2ee1e02 --- /dev/null +++ b/script/bootstrap @@ -0,0 +1,143 @@ +#!/bin/sh +# script/bootstrap: install all dependencies needed to build and develop +# this repo. Idempotent: every install is guarded by a check so already +# installed tools are skipped. Base tooling comes from nix, apt, brew, +# or apk (detected in that order); assumes nothing is present. Node is +# used directly if installed; otherwise it is installed at a pinned +# version via nvm (installing nvm itself first, from a hash-verified +# release archive, never curl | sh). +# +# The Go install at the end of main() is this repo's addition. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +# Pinned versions, 2026-07-06 +NODE_VERSION="22.17.0" +NVM_VERSION="0.40.3" +# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz +NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0" +YARN_VERSION="1.22.22" + +PKGMGR="" +SUDO="" + +detect_pkgmgr() { + [ -n "$PKGMGR" ] && return 0 + if command -v nix-env >/dev/null 2>&1; then + PKGMGR="nix" + elif command -v apt-get >/dev/null 2>&1; then + PKGMGR="apt" + elif command -v brew >/dev/null 2>&1; then + PKGMGR="brew" + elif command -v apk >/dev/null 2>&1; then + PKGMGR="apk" + else + echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2 + exit 1 + fi + if [ "$PKGMGR" = "apt" ]; then + export DEBIAN_FRONTEND=noninteractive + if [ "$(id -u)" != "0" ]; then + SUDO="sudo" + fi + fi +} + +# pkg_install +pkg_install() { + detect_pkgmgr + case "$PKGMGR" in + nix) nix-env -iA "nixpkgs.$1" ;; + apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;; + brew) brew install "$3" ;; + apk) apk add --no-cache "$4" ;; + esac +} + +missing() { + ! command -v "$1" >/dev/null 2>&1 +} + +# verify_sha256 +verify_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$1" | cut -d' ' -f1)" + else + actual="$(shasum -a 256 "$1" | cut -d' ' -f1)" + fi + if [ "$actual" != "$2" ]; then + echo "bootstrap: sha256 mismatch for $1" >&2 + echo " expected: $2" >&2 + echo " actual: $actual" >&2 + exit 1 + fi +} + +# nvm is a bash script; run a command in a bash with nvm loaded +nvm_sh() { + bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*" +} + +ensure_nvm() { + [ -s "$HOME/.nvm/nvm.sh" ] && return 0 + # nvm prerequisites; nvm itself requires bash + if missing bash; then pkg_install bash bash bash bash; fi + if missing curl; then pkg_install curl curl curl curl; fi + if missing git; then pkg_install git git git git; fi + tmp="$(mktemp -d)" + curl -fsSL -o "$tmp/nvm.tar.gz" \ + "https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz" + verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256" + mkdir -p "$HOME/.nvm" + tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1 + rm -rf "$tmp" +} + +ensure_node() { + if ! missing node; then return 0; fi + ensure_nvm + nvm_sh "nvm install $NODE_VERSION" +} + +ensure_yarn() { + if ! missing yarn; then return 0; fi + if ! missing corepack; then + corepack enable + corepack prepare "yarn@$YARN_VERSION" --activate + elif [ -s "$HOME/.nvm/nvm.sh" ]; then + nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \ + corepack prepare yarn@$YARN_VERSION --activate" + else + npm install -g "yarn@$YARN_VERSION" + fi +} + +install_js_deps() { + if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then + nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \ + yarn install --frozen-lockfile" + else + yarn install --frozen-lockfile + fi +} + +main() { + cd "$ROOT" + + if missing make; then pkg_install gnumake make make make; fi + if missing git; then pkg_install git git git git; fi + + ensure_node + ensure_yarn + install_js_deps + + # Go, for make build, script/precommit and the gofmt step of + # script/fmt and script/fmt-check. golangci-lint is not installed: it + # runs only in the Dockerfile's lint phase. + if missing go; then pkg_install go golang go go; fi + + echo "bootstrap complete" +} + +main "$@" diff --git a/script/check b/script/check new file mode 100755 index 0000000..92875f7 --- /dev/null +++ b/script/check @@ -0,0 +1,16 @@ +#!/bin/sh +# script/check: run all checks (test, lint, fmt-check). Our own +# extension to scripts-to-rule-them-all. test and lint are Docker +# phases; fmt-check is native, because a formatter writes the working +# tree. Must not modify any files. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" + +main() { + "$SCRIPT_DIR/test" + "$SCRIPT_DIR/lint" + "$SCRIPT_DIR/fmt-check" +} + +main "$@" diff --git a/script/cibuild b/script/cibuild new file mode 100755 index 0000000..d8d3200 --- /dev/null +++ b/script/cibuild @@ -0,0 +1,28 @@ +#!/bin/sh +# script/cibuild: run the CI build. It bootstraps first: a CI runner +# checks out and runs this and nothing else, and script/fmt-check runs +# the formatter on the host, which a pristine checkout cannot do. +# --no-cache for the same reason as script/docker: the gate phases the +# final stage depends on are RUN steps, and a cached one is a check that +# did not run. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + "$SCRIPT_DIR/bootstrap" + "$SCRIPT_DIR/check" + # Own line: a failing command substitution inside an argument does + # not trip `set -e`, so the inline form degrades silently to an + # empty constant. The VERSION build argument takes precedence over + # the version a build stage derives from the .git in the context. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$("$SCRIPT_DIR/projectname")" . +} + +main "$@" diff --git a/script/docker b/script/docker new file mode 100755 index 0000000..07b626c --- /dev/null +++ b/script/docker @@ -0,0 +1,24 @@ +#!/bin/sh +# script/docker: build the Docker image tagged with the project name. +# Identical in all repos; the tag comes from script/projectname. +# --no-cache because the gate phases the final stage depends on are RUN +# steps, and a cached one is a check that did not run. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + # Own line: a failing command substitution inside an argument does + # not trip `set -e`, so the inline form degrades silently to an + # empty constant. The VERSION build argument takes precedence over + # the version a build stage derives from the .git in the context. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$("$SCRIPT_DIR/projectname")" . +} + +main "$@" diff --git a/script/fmt b/script/fmt new file mode 100755 index 0000000..9fa3468 --- /dev/null +++ b/script/fmt @@ -0,0 +1,35 @@ +#!/bin/sh +# script/fmt: format all files (writes). +# +# The gofmt step in main() is this repo's addition. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +# Must match the pin in script/bootstrap. +NODE_VERSION="22.17.0" + +# script/bootstrap installs node and yarn under nvm and leaves neither +# on the PATH of the shell that called it, so resolve the pinned +# toolchain here the way bootstrap's own install step does. nvm is a +# bash script, hence the subshell. +run_yarn() { + if command -v yarn >/dev/null 2>&1; then + exec yarn "$@" + fi + if [ ! -s "$HOME/.nvm/nvm.sh" ]; then + echo "fmt: no yarn; run script/bootstrap first" >&2 + exit 1 + fi + exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null && + shift && exec yarn "$@"' bash "$NODE_VERSION" "$@" +} + +main() { + cd "$ROOT" + # Before prettier, because run_yarn replaces this shell with yarn. + gofmt -s -w . + run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always +} + +main "$@" diff --git a/script/fmt-check b/script/fmt-check new file mode 100755 index 0000000..5ffa381 --- /dev/null +++ b/script/fmt-check @@ -0,0 +1,40 @@ +#!/bin/sh +# script/fmt-check: check formatting (read-only). +# +# The gofmt check in main() is this repo's addition. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +# Must match the pin in script/bootstrap. +NODE_VERSION="22.17.0" + +# script/bootstrap installs node and yarn under nvm and leaves neither +# on the PATH of the shell that called it, so resolve the pinned +# toolchain here the way bootstrap's own install step does. nvm is a +# bash script, hence the subshell. +run_yarn() { + if command -v yarn >/dev/null 2>&1; then + exec yarn "$@" + fi + if [ ! -s "$HOME/.nvm/nvm.sh" ]; then + echo "fmt-check: no yarn; run script/bootstrap first" >&2 + exit 1 + fi + exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null && + shift && exec yarn "$@"' bash "$NODE_VERSION" "$@" +} + +main() { + cd "$ROOT" + # Before prettier, because run_yarn replaces this shell with yarn. + unformatted="$(gofmt -s -l .)" + if [ -n "$unformatted" ]; then + echo "fmt-check: run make fmt; gofmt would change:" >&2 + echo "$unformatted" >&2 + exit 1 + fi + run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always +} + +main "$@" diff --git a/script/install-precommit b/script/install-precommit new file mode 100755 index 0000000..bef6406 --- /dev/null +++ b/script/install-precommit @@ -0,0 +1,16 @@ +#!/bin/sh +# script/install-precommit: install the git pre-commit hook that runs +# script/precommit. Our own extension to scripts-to-rule-them-all. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + hook=".git/hooks/pre-commit" + printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit + chmod +x .git/hooks/pre-commit + echo "pre-commit hook installed: runs script/precommit" +} + +main "$@" diff --git a/script/lint b/script/lint new file mode 100755 index 0000000..2d8b075 --- /dev/null +++ b/script/lint @@ -0,0 +1,23 @@ +#!/bin/sh +# script/lint: run the linter. Linting is a phase of the Dockerfile and +# this builds that phase alone; the linter is never installed or run on +# a developer host, where a shared result cache and a host-global lock +# make its answer untrustworthy. +# +# The phase is not the last stage in the file, so it is built only when +# --target names it. --no-cache because a cached lint layer is a lint +# that did not run. The tag makes each build replace the previous image +# instead of leaving a dangling one behind. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + docker build --no-cache \ + --target lint \ + -t "$("$SCRIPT_DIR/projectname")-lint" . +} + +main "$@" diff --git a/script/precommit b/script/precommit new file mode 100755 index 0000000..1e8d377 --- /dev/null +++ b/script/precommit @@ -0,0 +1,23 @@ +#!/bin/sh +# script/precommit: run by the git pre-commit hook; fails the commit if +# checks fail. Our own extension to scripts-to-rule-them-all. +# +# This repo's addition: go mod tidy runs first, failing the commit if it +# changes go.mod or go.sum. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + go mod tidy + git diff --exit-code -- go.mod go.sum || { + echo "precommit: go mod tidy changed go.mod or go.sum;" \ + "stage the changes and retry" >&2 + exit 1 + } + "$SCRIPT_DIR/check" +} + +main "$@" diff --git a/script/projectname b/script/projectname new file mode 100755 index 0000000..fe4df79 --- /dev/null +++ b/script/projectname @@ -0,0 +1,12 @@ +#!/bin/sh +# script/projectname: output the name of this project. Our own +# extension to scripts-to-rule-them-all. Other scripts that need the +# name (e.g. script/docker) call this, so they can stay identical +# across all repos. +set -eu + +main() { + echo "neoirc" +} + +main "$@" diff --git a/script/setup b/script/setup new file mode 100755 index 0000000..4cc5b6b --- /dev/null +++ b/script/setup @@ -0,0 +1,13 @@ +#!/bin/sh +# script/setup: set up the repo for development after a fresh clone: +# installs dependencies and the git pre-commit hook. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" + +main() { + "$SCRIPT_DIR/bootstrap" + "$SCRIPT_DIR/install-precommit" +} + +main "$@" diff --git a/script/test b/script/test new file mode 100755 index 0000000..cd239f2 --- /dev/null +++ b/script/test @@ -0,0 +1,19 @@ +#!/bin/sh +# script/test: run the test suite. Testing is a phase of the Dockerfile +# and this builds that phase alone, on the same terms as script/lint: +# --target because a phase that is not the last stage is built only when +# named, --no-cache because a cached test layer is a test that did not +# run, and a tag so each build replaces the previous image. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + docker build --no-cache \ + --target test \ + -t "$("$SCRIPT_DIR/projectname")-test" . +} + +main "$@" diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..d846639 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +prettier@3.8.1: + version "3.8.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173" + integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== -- 2.54.0 From 6acf2ca3b4f4c1d76120c7a69f11ad56c260aca4 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 7 Oct 2026 00:13:40 +0200 Subject: [PATCH 06/40] Make the internal/handlers tests fast (closes #113) Nearly all of the package's test time was SQLite work, and much of it was setup every test repeated: each test migrated a fresh schema, and each new session wrote the seven-line default MOTD to the database. TestMain now migrates one in-memory template database once, and each test starts from its own copy of it, made with SQLite's backup API. The test config's MOTD is one line. Every test still gets an empty database of its own, and every check runs as before. Model: opus-5-5 --- internal/handlers/api_test.go | 76 ++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index b6635a8..594ef40 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -6,7 +6,10 @@ package handlers_test import ( "bytes" + "context" + "database/sql" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -22,6 +25,7 @@ import ( "go.uber.org/fx" "go.uber.org/fx/fxtest" "golang.org/x/crypto/bcrypt" + "modernc.org/sqlite" "sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/db" @@ -37,9 +41,76 @@ import ( "sneak.berlin/go/neoirc/pkg/irc" ) +// templateDBURL names the in-memory database TestMain migrates once +// and every test copies; it lasts while TestMain holds it open. +const templateDBURL = "file:template?mode=memory&cache=shared" + func TestMain(m *testing.M) { db.SetBcryptCost(bcrypt.MinCost) - os.Exit(m.Run()) + + template, err := sql.Open("sqlite", templateDBURL) + if err != nil { + panic(err) + } + + err = db.NewTestDatabaseFromConn(template). + RunMigrations(context.Background()) + if err != nil { + panic(err) + } + + code := m.Run() + + _ = template.Close() + + os.Exit(code) +} + +// copyTemplateDB creates the in-memory database dbURL as a copy of +// the migrated template, so a test does not migrate the schema +// again. The copy lasts until the test ends. +func copyTemplateDB(t *testing.T, dbURL string) { + t.Helper() + + database, err := sql.Open("sqlite", dbURL) + if err != nil { + t.Fatalf("open test db: %v", err) + } + + t.Cleanup(func() { _ = database.Close() }) + + conn, err := database.Conn(t.Context()) + if err != nil { + t.Fatalf("connect test db: %v", err) + } + + defer func() { _ = conn.Close() }() + + err = conn.Raw(func(driverConn any) error { + restorer, ok := driverConn.(interface { + NewRestore(srcURI string) (*sqlite.Backup, error) + }) + if !ok { + return errors.ErrUnsupported + } + + restore, restoreErr := restorer.NewRestore(templateDBURL) + if restoreErr != nil { + return fmt.Errorf("start restore: %w", restoreErr) + } + + _, restoreErr = restore.Step(-1) + if restoreErr != nil { + _ = restore.Finish() + + return fmt.Errorf("restore: %w", restoreErr) + } + + return restore.Finish() + }) + if err != nil { + t.Fatalf("copy template db: %v", err) + } } const ( @@ -84,6 +155,7 @@ func newTestServerWith( "file:test_%p?mode=memory&cache=shared", t, ) + copyTemplateDB(t, dbURL) var srv *server.Server @@ -109,6 +181,8 @@ func newTestServerWith( cfg.DBURL = dbURL cfg.Port = 0 + // One line: each line is a database write per new session. + cfg.MOTD = "neoirc test server" cfg.HashcashBits = hashcashBits cfg.OperName = operName cfg.OperPassword = operPassword -- 2.54.0 From b119b094f5279fb0009b802e98c06d1a79655024 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 7 Oct 2026 02:01:42 +0200 Subject: [PATCH 07/40] Build the web front end with yarn (closes #117) web/yarn.lock replaces web/package-lock.json. yarn import converted it, so it locks the same package versions with the same integrity hashes. The web-builder stage pins yarn 1.22.22, the version script/bootstrap pins, by its sha512 through corepack, and installs with yarn install --frozen-lockfile. web/build.sh runs the esbuild that yarn installed rather than npx or an esbuild found on the PATH. Model: opus-5-5 --- Dockerfile | 10 +- README.md | 2 +- web/build.sh | 16 +- web/package-lock.json | 513 ------------------------------------------ web/yarn.lock | 170 ++++++++++++++ 5 files changed, 184 insertions(+), 527 deletions(-) delete mode 100644 web/package-lock.json create mode 100644 web/yarn.lock diff --git a/Dockerfile b/Dockerfile index 371400a..d8fae5c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,15 @@ # Web build stage — compile SPA from source # node:22-alpine, 2026-03-09 FROM node@sha256:8094c002d08262dba12645a3b4a15cd6cd627d30bc782f53229a2ec13ee22a00 AS web-builder +# yarn 1.22.22, 2026-10-06 +# The version script/bootstrap pins, checked against the sha512 of its +# npm package. corepack enable replaces the yarn the image ships with +# corepack's own, which runs the version activated here. +RUN corepack enable \ + && corepack prepare yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e --activate WORKDIR /web -COPY web/package.json web/package-lock.json ./ -RUN npm ci +COPY web/package.json web/yarn.lock ./ +RUN yarn install --frozen-lockfile COPY web/src/ src/ COPY web/build.sh build.sh RUN sh build.sh diff --git a/README.md b/README.md index e1ed939..3612738 100644 --- a/README.md +++ b/README.md @@ -3020,7 +3020,7 @@ neoirc/ │ ├── embed.go # go:embed directive for SPA │ ├── build.sh # SPA build script (esbuild, runs in Docker) │ ├── package.json # Node dependencies (preact, esbuild) -│ ├── package-lock.json +│ ├── yarn.lock │ ├── src/ # SPA source files (JSX + HTML + CSS) │ │ ├── app.jsx │ │ ├── index.html diff --git a/web/build.sh b/web/build.sh index e799aac..477d617 100755 --- a/web/build.sh +++ b/web/build.sh @@ -2,22 +2,16 @@ set -e cd "$(dirname "$0")" -# Install esbuild if not present -if ! command -v esbuild >/dev/null 2>&1; then - if command -v npx >/dev/null 2>&1; then - NPX="npx" - else - echo "esbuild not found. Install it: npm install -g esbuild" - exit 1 - fi -else - NPX="" +# The esbuild that yarn.lock pins, which yarn install puts in node_modules +if [ ! -x node_modules/.bin/esbuild ]; then + echo "esbuild not found. Install it: yarn install --frozen-lockfile" + exit 1 fi mkdir -p dist # Build JS bundle — preact must be bundled (no CDN/external loader) -${NPX:+$NPX} esbuild src/app.jsx \ +node_modules/.bin/esbuild src/app.jsx \ --bundle \ --minify \ --format=esm \ diff --git a/web/package-lock.json b/web/package-lock.json deleted file mode 100644 index f45ba3e..0000000 --- a/web/package-lock.json +++ /dev/null @@ -1,513 +0,0 @@ -{ - "name": "web", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "web", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "preact": "^10.28.3" - }, - "devDependencies": { - "esbuild": "^0.27.3" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, - "node_modules/preact": { - "version": "10.28.3", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz", - "integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - } - } -} diff --git a/web/yarn.lock b/web/yarn.lock new file mode 100644 index 0000000..343d371 --- /dev/null +++ b/web/yarn.lock @@ -0,0 +1,170 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@esbuild/aix-ppc64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2" + integrity sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg== + +"@esbuild/android-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz#19b882408829ad8e12b10aff2840711b2da361e8" + integrity sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg== + +"@esbuild/android-arm@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz#90be58de27915efa27b767fcbdb37a4470627d7b" + integrity sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA== + +"@esbuild/android-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz#d7dcc976f16e01a9aaa2f9b938fbec7389f895ac" + integrity sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ== + +"@esbuild/darwin-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz#9f6cac72b3a8532298a6a4493ed639a8988e8abd" + integrity sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg== + +"@esbuild/darwin-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz#ac61d645faa37fd650340f1866b0812e1fb14d6a" + integrity sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg== + +"@esbuild/freebsd-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz#b8625689d73cf1830fe58c39051acdc12474ea1b" + integrity sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w== + +"@esbuild/freebsd-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz#07be7dd3c9d42fe0eccd2ab9f9ded780bc53bead" + integrity sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA== + +"@esbuild/linux-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz#bf31918fe5c798586460d2b3d6c46ed2c01ca0b6" + integrity sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg== + +"@esbuild/linux-arm@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz#28493ee46abec1dc3f500223cd9f8d2df08f9d11" + integrity sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw== + +"@esbuild/linux-ia32@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz#750752a8b30b43647402561eea764d0a41d0ee29" + integrity sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg== + +"@esbuild/linux-loong64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz#a5a92813a04e71198c50f05adfaf18fc1e95b9ed" + integrity sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA== + +"@esbuild/linux-mips64el@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz#deb45d7fd2d2161eadf1fbc593637ed766d50bb1" + integrity sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw== + +"@esbuild/linux-ppc64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz#6f39ae0b8c4d3d2d61a65b26df79f6e12a1c3d78" + integrity sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA== + +"@esbuild/linux-riscv64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz#4c5c19c3916612ec8e3915187030b9df0b955c1d" + integrity sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ== + +"@esbuild/linux-s390x@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz#9ed17b3198fa08ad5ccaa9e74f6c0aff7ad0156d" + integrity sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw== + +"@esbuild/linux-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz#12383dcbf71b7cf6513e58b4b08d95a710bf52a5" + integrity sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA== + +"@esbuild/netbsd-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz#dd0cb2fa543205fcd931df44f4786bfcce6df7d7" + integrity sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA== + +"@esbuild/netbsd-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz#028ad1807a8e03e155153b2d025b506c3787354b" + integrity sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA== + +"@esbuild/openbsd-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz#e3c16ff3490c9b59b969fffca87f350ffc0e2af5" + integrity sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw== + +"@esbuild/openbsd-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz#c5a4693fcb03d1cbecbf8b422422468dfc0d2a8b" + integrity sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ== + +"@esbuild/openharmony-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz#082082444f12db564a0775a41e1991c0e125055e" + integrity sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g== + +"@esbuild/sunos-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz#5ab036c53f929e8405c4e96e865a424160a1b537" + integrity sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA== + +"@esbuild/win32-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz#38de700ef4b960a0045370c171794526e589862e" + integrity sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA== + +"@esbuild/win32-ia32@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz#451b93dc03ec5d4f38619e6cd64d9f9eff06f55c" + integrity sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q== + +"@esbuild/win32-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17" + integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA== + +esbuild@^0.27.3: + version "0.27.3" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.3.tgz#5859ca8e70a3af956b26895ce4954d7e73bd27a8" + integrity sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg== + optionalDependencies: + "@esbuild/aix-ppc64" "0.27.3" + "@esbuild/android-arm" "0.27.3" + "@esbuild/android-arm64" "0.27.3" + "@esbuild/android-x64" "0.27.3" + "@esbuild/darwin-arm64" "0.27.3" + "@esbuild/darwin-x64" "0.27.3" + "@esbuild/freebsd-arm64" "0.27.3" + "@esbuild/freebsd-x64" "0.27.3" + "@esbuild/linux-arm" "0.27.3" + "@esbuild/linux-arm64" "0.27.3" + "@esbuild/linux-ia32" "0.27.3" + "@esbuild/linux-loong64" "0.27.3" + "@esbuild/linux-mips64el" "0.27.3" + "@esbuild/linux-ppc64" "0.27.3" + "@esbuild/linux-riscv64" "0.27.3" + "@esbuild/linux-s390x" "0.27.3" + "@esbuild/linux-x64" "0.27.3" + "@esbuild/netbsd-arm64" "0.27.3" + "@esbuild/netbsd-x64" "0.27.3" + "@esbuild/openbsd-arm64" "0.27.3" + "@esbuild/openbsd-x64" "0.27.3" + "@esbuild/openharmony-arm64" "0.27.3" + "@esbuild/sunos-x64" "0.27.3" + "@esbuild/win32-arm64" "0.27.3" + "@esbuild/win32-ia32" "0.27.3" + "@esbuild/win32-x64" "0.27.3" + +preact@^10.28.3: + version "10.28.3" + resolved "https://registry.yarnpkg.com/preact/-/preact-10.28.3.tgz#3c2171526b3e29628ad1a6c56a9e3ca867bbdee8" + integrity sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA== -- 2.54.0 From 0f2e63d0cc2a17f532cb932ead2d6ca3e0b5ecc5 Mon Sep 17 00:00:00 2001 From: clawbot Date: Thu, 8 Oct 2026 07:31:37 +0200 Subject: [PATCH 08/40] Add the tier 3 utility IRC commands (closes #87) USERHOST, VERSION, ADMIN, INFO, TIME, KILL and WALLOPS work on the HTTP API and on the IRC listener. The USERHOST reply, the VERSION, ADMIN and INFO text, KILL and WALLOPS are each one service function that both transports call. User mode +w selects who receives WALLOPS. MODE on your own nick queries or changes your user modes through one parser on both transports: the whole mode string is checked before anything is stored, and both flags are stored in one transaction. KILL closes the victim's IRC connection after sending it KILL and ERROR, without the operator waiting on it. The sessions table gains is_wallops in 001_initial.sql, as the pre-1.0 rule in REPO_POLICIES.md asks. Model: opus-5-5 Co-authored-by: clawbot --- README.md | 82 +++-- internal/db/export_test.go | 14 + internal/db/queries.go | 170 +++++++++++ internal/db/queries_test.go | 116 ++++++++ internal/db/schema/001_initial.sql | 1 + internal/handlers/api.go | 44 ++- internal/handlers/api_test.go | 1 + internal/handlers/utility.go | 305 +++++++++++++++++++ internal/handlers/utility_test.go | 335 +++++++++++++++++++++ internal/ircserver/commands.go | 175 ++++++++--- internal/ircserver/conn.go | 118 +++++++- internal/ircserver/conn_test.go | 102 +++++++ internal/ircserver/export_test.go | 31 +- internal/ircserver/integration_test.go | 394 +++++++++++++++++++++++++ internal/ircserver/relay.go | 14 + internal/ircserver/server_test.go | 83 +++++- internal/service/service.go | 389 +++++++++++++++++++++++- internal/service/service_test.go | 256 +++++++++++++++- pkg/irc/commands.go | 51 ++-- 19 files changed, 2552 insertions(+), 129 deletions(-) create mode 100644 internal/handlers/utility.go create mode 100644 internal/handlers/utility_test.go create mode 100644 internal/ircserver/conn_test.go diff --git a/README.md b/README.md index 3612738..6080c68 100644 --- a/README.md +++ b/README.md @@ -1575,24 +1575,31 @@ reference with all required and optional fields. **Command dispatch table:** -| Command | Required Fields | Optional | Response Status | -| --------- | --------------- | -------- | --------------- | -| `PRIVMSG` | `to`, `body` | `meta` | 200 OK | -| `NOTICE` | `to`, `body` | `meta` | 200 OK | -| `JOIN` | `to` | | 200 OK | -| `PART` | `to` | `body` | 200 OK | -| `NICK` | `body` | | 200 OK | -| `PASS` | `body` | | 200 OK | -| `TOPIC` | `to`, `body` | | 200 OK | -| `MODE` | `to` | | 200 OK | -| `NAMES` | `to` | | 200 OK | -| `LIST` | | | 200 OK | -| `WHOIS` | `to` or `body` | | 200 OK | -| `WHO` | `to` | | 200 OK | -| `LUSERS` | | | 200 OK | -| `OPER` | `body` | | 200 OK | -| `QUIT` | | `body` | 200 OK | -| `PING` | | | 200 OK | +| Command | Required Fields | Optional | Response Status | +| ---------- | --------------- | -------- | --------------- | +| `PRIVMSG` | `to`, `body` | `meta` | 200 OK | +| `NOTICE` | `to`, `body` | `meta` | 200 OK | +| `JOIN` | `to` | | 200 OK | +| `PART` | `to` | `body` | 200 OK | +| `NICK` | `body` | | 200 OK | +| `PASS` | `body` | | 200 OK | +| `TOPIC` | `to`, `body` | | 200 OK | +| `MODE` | `to` | `body` | 200 OK | +| `NAMES` | `to` | | 200 OK | +| `LIST` | | | 200 OK | +| `WHOIS` | `to` or `body` | | 200 OK | +| `WHO` | `to` | | 200 OK | +| `LUSERS` | | | 200 OK | +| `USERHOST` | `body` | | 200 OK | +| `VERSION` | | | 200 OK | +| `ADMIN` | | | 200 OK | +| `INFO` | | | 200 OK | +| `TIME` | | | 200 OK | +| `OPER` | `body` | | 200 OK | +| `KILL` | `body` | | 200 OK | +| `WALLOPS` | `body` | | 200 OK | +| `QUIT` | | `body` | 200 OK | +| `PING` | | | 200 OK | All IRC commands return HTTP 200 OK. IRC-level success and error responses are delivered as **numeric replies** through the message queue (see @@ -1619,7 +1626,11 @@ auth cookies (401), and server errors (500). | 433 | ERR_NICKNAMEINUSE | NICK target is taken | | 442 | ERR_NOTONCHANNEL | Not a member of the target channel | | 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) | +| 481 | ERR_NOPRIVILEGES | KILL or WALLOPS by a non-operator | +| 483 | ERR_CANTKILLSERVER | KILL of yourself | | 491 | ERR_NOOPERHOST | Failed OPER authentication | +| 501 | ERR_UMODEUNKNOWNFLAG | User MODE string not accepted | +| 502 | ERR_USERSDONTMATCH | User MODE for a nick other than your own | **IRC numeric success replies (delivered via message queue):** @@ -1630,11 +1641,13 @@ auth cookies (401), and server errors (500). | 003 | RPL_CREATED | Sent on session creation/login | | 004 | RPL_MYINFO | Sent on session creation/login | | 005 | RPL_ISUPPORT | Sent on session creation/login | -| 221 | RPL_UMODEIS | In response to user MODE query | +| 221 | RPL_UMODEIS | In response to user MODE | | 251 | RPL_LUSERCLIENT | On connect or LUSERS command | | 252 | RPL_LUSEROP | On connect or LUSERS command | | 254 | RPL_LUSERCHANNELS | On connect or LUSERS command | | 255 | RPL_LUSERME | On connect or LUSERS command | +| 256–259 | RPL_ADMINME etc. | ADMIN info | +| 302 | RPL_USERHOST | USERHOST reply | | 311 | RPL_WHOISUSER | WHOIS user info | | 312 | RPL_WHOISSERVER | WHOIS server info | | 313 | RPL_WHOISOPERATOR | WHOIS target is oper | @@ -1642,6 +1655,10 @@ auth cookies (401), and server errors (500). | 318 | RPL_ENDOFWHOIS | End of WHOIS list | | 319 | RPL_WHOISCHANNELS | WHOIS channels list | | 338 | RPL_WHOISACTUALLY | WHOIS client IP (oper-only) | +| 351 | RPL_VERSION | VERSION reply | +| 371 | RPL_INFO | INFO line | +| 374 | RPL_ENDOFINFO | End of INFO | +| 391 | RPL_TIME | TIME reply | | 322 | RPL_LIST | Channel in LIST response | | 323 | RPL_LISTEND | End of LIST | | 324 | RPL_CHANNELMODEIS | Channel mode query response | @@ -2384,13 +2401,13 @@ IRC_LISTEN_ADDR= ### Supported Commands -| Category | Commands | -| ---------- | ------------------------------------------------------------------ | -| Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` | -| Channels | `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE` | -| Messaging | `PRIVMSG`, `NOTICE` | -| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY` | -| Operator | `OPER` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) | +| Category | Commands | +| ---------- | ---------------------------------------------------------------------------------------- | +| Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` | +| Channels | `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE` | +| Messaging | `PRIVMSG`, `NOTICE` | +| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY`, `USERHOST`, `VERSION`, `ADMIN`, `INFO`, `TIME` | +| Operator | `OPER`, `KILL`, `WALLOPS` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) | ### Protocol Details @@ -2406,6 +2423,15 @@ IRC_LISTEN_ADDR= operator status (`@`). - **Channel modes**: `+m` (moderated), `+t` (topic lock), `+o` (operator), `+v` (voice) +- **User modes**: `+o` (operator, set only via `OPER`), `+w` (receives + `WALLOPS`). `MODE` for any nick other than your own is rejected with + `ERR_USERSDONTMATCH` (502), for both queries and changes. Nick comparison is + case-insensitive. +- **KILL**: an operator's `KILL` broadcasts the victim's `QUIT` to its channel + peers, deletes its session, and then sends the victim a `KILL` and + `ERROR :Closing Link` before closing its socket. This applies to victims on + the IRC listener regardless of whether the `KILL` arrived over IRC or the HTTP + API. ### Bridge to HTTP API @@ -2946,6 +2972,10 @@ guess is borne by the server (bcrypt), not the client. from additional devices via `POST /api/v1/login` - [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for all API authentication +- [x] **Tier 3 utility commands** — `USERHOST` (302), `VERSION` (351), `ADMIN` + (256–259), `INFO` (371/374), `TIME` (391), `KILL` (operator-only forced + disconnect), `WALLOPS` (operator-only broadcast to `+w` users) +- [x] **User mode +w** — receive `WALLOPS`, set with `MODE +w` / `-w` ### Future (1.0+) diff --git a/internal/db/export_test.go b/internal/db/export_test.go index 45c0435..f233380 100644 --- a/internal/db/export_test.go +++ b/internal/db/export_test.go @@ -58,3 +58,17 @@ func (database *Database) Close() error { return nil } + +// ExecForTest runs a raw statement, such as a trigger that +// makes a write fail. +func (database *Database) ExecForTest( + ctx context.Context, + query string, +) error { + _, err := database.conn.ExecContext(ctx, query) + if err != nil { + return fmt.Errorf("exec for test: %w", err) + } + + return nil +} diff --git a/internal/db/queries.go b/internal/db/queries.go index d840773..7b437df 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -7,6 +7,7 @@ import ( "database/sql" "encoding/hex" "encoding/json" + "errors" "fmt" "strconv" "strings" @@ -2423,3 +2424,172 @@ func (database *Database) SetChannelUserLimit( return nil } + +// SetSessionUserModes sets a session's wallops (+w) and +// oper (+o) flags in one transaction, so that either both +// changes are stored or neither is. A nil flag is left as +// it is. +func (database *Database) SetSessionUserModes( + ctx context.Context, + sessionID int64, + wallops *bool, + oper *bool, +) error { + transaction, err := database.conn.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + + if wallops != nil { + _, err = transaction.ExecContext( + ctx, + `UPDATE sessions SET is_wallops = ? WHERE id = ?`, + boolToInt(*wallops), sessionID, + ) + if err != nil { + _ = transaction.Rollback() + + return fmt.Errorf("set session wallops: %w", err) + } + } + + if oper != nil { + _, err = transaction.ExecContext( + ctx, + `UPDATE sessions SET is_oper = ? WHERE id = ?`, + boolToInt(*oper), sessionID, + ) + if err != nil { + _ = transaction.Rollback() + + return fmt.Errorf("set session oper: %w", err) + } + } + + err = transaction.Commit() + if err != nil { + return fmt.Errorf("commit user modes: %w", err) + } + + return nil +} + +// boolToInt returns the 0 or 1 that the schema stores for a +// boolean. +func boolToInt(value bool) int { + if value { + return 1 + } + + return 0 +} + +// IsSessionWallops returns whether the session has the +// wallops (+w) usermode set. +func (database *Database) IsSessionWallops( + ctx context.Context, + sessionID int64, +) (bool, error) { + var isWallops int + + err := database.conn.QueryRowContext( + ctx, + `SELECT is_wallops FROM sessions WHERE id = ?`, + sessionID, + ).Scan(&isWallops) + if err != nil { + return false, fmt.Errorf( + "check session wallops: %w", err, + ) + } + + return isWallops != 0, nil +} + +// GetWallopsSessionIDs returns all session IDs that have +// the wallops (+w) usermode set. +func (database *Database) GetWallopsSessionIDs( + ctx context.Context, +) ([]int64, error) { + rows, err := database.conn.QueryContext( + ctx, + `SELECT id FROM sessions WHERE is_wallops = 1`, + ) + if err != nil { + return nil, fmt.Errorf( + "get wallops sessions: %w", err, + ) + } + + defer func() { _ = rows.Close() }() + + var ids []int64 + + for rows.Next() { + var sessionID int64 + + err = rows.Scan(&sessionID) + if err != nil { + return nil, fmt.Errorf( + "scan wallops session: %w", err, + ) + } + + ids = append(ids, sessionID) + } + + err = rows.Err() + if err != nil { + return nil, fmt.Errorf( + "iterate wallops sessions: %w", err, + ) + } + + return ids, nil +} + +// UserhostInfo holds the data needed for RPL_USERHOST. +type UserhostInfo struct { + Nick string + Username string + Hostname string + IsOper bool + AwayMessage string +} + +// GetUserhostInfo returns USERHOST info for the given +// nicks. Nicks with no session are left out. +func (database *Database) GetUserhostInfo( + ctx context.Context, + nicks []string, +) ([]UserhostInfo, error) { + results := make([]UserhostInfo, 0, len(nicks)) + + for _, nick := range nicks { + var info UserhostInfo + + err := database.conn.QueryRowContext( + ctx, + `SELECT nick, username, hostname, + is_oper, away_message + FROM sessions WHERE nick = ?`, + nick, + ).Scan( + &info.Nick, &info.Username, &info.Hostname, + &info.IsOper, &info.AwayMessage, + ) + if errors.Is(err, sql.ErrNoRows) { + continue + } + + if err != nil { + return nil, fmt.Errorf( + "userhost lookup %q: %w", nick, err, + ) + } + + results = append(results, info) + } + + return results, nil +} diff --git a/internal/db/queries_test.go b/internal/db/queries_test.go index 9c201d2..d5467a9 100644 --- a/internal/db/queries_test.go +++ b/internal/db/queries_test.go @@ -1491,3 +1491,119 @@ func TestChannelUserLimit(t *testing.T) { t.Fatalf("expected 0, got %d", limit) } } + +// TestSetSessionUserModesIsAtomic makes the is_oper write +// fail after the is_wallops write has run, and checks that +// the wallops change was rolled back. +func TestSetSessionUserModesIsAtomic(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + sessionID, _, _, err := database.CreateSession( + ctx, "alice", "", "", "", + ) + if err != nil { + t.Fatal(err) + } + + err = database.ExecForTest(ctx, + `CREATE TRIGGER reject_oper + BEFORE UPDATE OF is_oper ON sessions + BEGIN SELECT RAISE(ABORT, 'oper write rejected'); + END`, + ) + if err != nil { + t.Fatal(err) + } + + wallops := true + oper := false + + err = database.SetSessionUserModes( + ctx, sessionID, &wallops, &oper, + ) + if err == nil { + t.Fatal("expected the rejected oper write to fail") + } + + gotWallops, err := database.IsSessionWallops( + ctx, sessionID, + ) + if err != nil { + t.Fatal(err) + } + + if gotWallops { + t.Error("wallops was stored although the change failed") + } +} + +// TestSetSessionUserModesAppliesBoth checks that both flags +// are written, and that a nil flag is left as it is. +func TestSetSessionUserModesAppliesBoth(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + sessionID, _, _, err := database.CreateSession( + ctx, "alice", "", "", "", + ) + if err != nil { + t.Fatal(err) + } + + err = database.SetSessionOper(ctx, sessionID, true) + if err != nil { + t.Fatal(err) + } + + wallops := true + oper := false + + err = database.SetSessionUserModes( + ctx, sessionID, &wallops, &oper, + ) + if err != nil { + t.Fatal(err) + } + + gotWallops, err := database.IsSessionWallops( + ctx, sessionID, + ) + if err != nil { + t.Fatal(err) + } + + gotOper, err := database.IsSessionOper(ctx, sessionID) + if err != nil { + t.Fatal(err) + } + + if !gotWallops || gotOper { + t.Errorf( + "want wallops=true oper=false, got %v/%v", + gotWallops, gotOper, + ) + } + + err = database.SetSessionUserModes( + ctx, sessionID, nil, nil, + ) + if err != nil { + t.Fatal(err) + } + + gotWallops, err = database.IsSessionWallops( + ctx, sessionID, + ) + if err != nil { + t.Fatal(err) + } + + if !gotWallops { + t.Error("a nil wallops flag cleared wallops") + } +} diff --git a/internal/db/schema/001_initial.sql b/internal/db/schema/001_initial.sql index e53c48b..1a1b27f 100644 --- a/internal/db/schema/001_initial.sql +++ b/internal/db/schema/001_initial.sql @@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS sessions ( hostname TEXT NOT NULL DEFAULT '', ip TEXT NOT NULL DEFAULT '', is_oper INTEGER NOT NULL DEFAULT 0, + is_wallops INTEGER NOT NULL DEFAULT 0, password_hash TEXT NOT NULL DEFAULT '', signing_key TEXT NOT NULL DEFAULT '', away_message TEXT NOT NULL DEFAULT '', diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 3da43f3..cde58bd 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -1015,10 +1015,10 @@ func (hdlr *Handlers) dispatchCommand( hdlr.handleQuit( writer, request, sessionID, nick, body, ) - case irc.CmdOper: - hdlr.handleOper( + case irc.CmdOper, irc.CmdKill, irc.CmdWallops: + hdlr.dispatchOperCommand( writer, request, - sessionID, clientID, nick, bodyLines, + sessionID, clientID, nick, command, bodyLines, ) case irc.CmdMotd, irc.CmdPing: hdlr.dispatchInfoCommand( @@ -1075,6 +1075,27 @@ func (hdlr *Handlers) dispatchQueryCommand( writer, request, sessionID, clientID, nick, ) + case irc.CmdUserhost: + hdlr.handleUserhost( + writer, request, + 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: hdlr.enqueueNumeric( request.Context(), clientID, @@ -1930,8 +1951,9 @@ func (hdlr *Handlers) handleQuit( http.StatusOK) } -// handleMode handles the MODE command for channels and -// users. Currently supports query-only (no mode changes). +// handleMode handles the MODE command: a target that starts +// with # is a channel, handled by handleChannelMode, and any +// other target a nick, handled by handleUserMode. func (hdlr *Handlers) handleMode( writer http.ResponseWriter, request *http.Request, @@ -1951,15 +1973,11 @@ func (hdlr *Handlers) handleMode( channel := target if !strings.HasPrefix(channel, "#") { - // User mode query — return empty modes. - hdlr.enqueueNumeric( - request.Context(), clientID, - irc.RplUmodeIs, nick, nil, "+", + hdlr.handleUserMode( + writer, request, + sessionID, clientID, nick, target, + bodyLines, ) - hdlr.broker.Notify(sessionID) - hdlr.respondJSON(writer, request, - map[string]string{statusKey: "ok"}, - http.StatusOK) return } diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 594ef40..4fff74e 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -293,6 +293,7 @@ func newTestHandlers( Config: cfg, Database: database, Broker: brk, + Globals: globs, }) hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct diff --git a/internal/handlers/utility.go b/internal/handlers/utility.go new file mode 100644 index 0000000..30dcb02 --- /dev/null +++ b/internal/handlers/utility.go @@ -0,0 +1,305 @@ +package handlers + +import ( + "net/http" + "strings" + "time" + + "sneak.berlin/go/neoirc/pkg/irc" +) + +// dispatchOperCommand routes the server operator commands +// OPER, KILL and WALLOPS to their handlers. +func (hdlr *Handlers) dispatchOperCommand( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, command string, + bodyLines func() []string, +) { + switch command { + case irc.CmdOper: + hdlr.handleOper( + writer, request, + sessionID, clientID, nick, bodyLines, + ) + case irc.CmdKill: + hdlr.handleKill( + writer, request, + sessionID, clientID, nick, bodyLines, + ) + case irc.CmdWallops: + hdlr.handleWallops( + writer, request, + sessionID, clientID, nick, bodyLines, + ) + } +} + +// handleUserhost handles USERHOST: user@host for each of +// up to five nicks given in the body. +func (hdlr *Handlers) handleUserhost( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, + bodyLines func() []string, +) { + lines := bodyLines() + if len(lines) == 0 { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNeedMoreParams, nick, + []string{irc.CmdUserhost}, + "Not enough parameters", + ) + + return + } + + reply, err := hdlr.svc.UserhostReply( + request.Context(), lines, hdlr.serverName(), + ) + if hdlr.handleServiceError( + writer, request, clientID, sessionID, nick, err, + ) { + return + } + + hdlr.enqueueNumeric( + request.Context(), clientID, + irc.RplUserHost, nick, nil, reply, + ) + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{statusKey: "ok"}, + http.StatusOK) +} + +// handleVersion handles VERSION. +func (hdlr *Handlers) handleVersion( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, +) { + hdlr.enqueueNumeric( + request.Context(), clientID, irc.RplVersion, nick, + []string{ + hdlr.svc.ServerVersion() + ".", + hdlr.serverName(), + }, + "", + ) + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{statusKey: "ok"}, + http.StatusOK) +} + +// handleAdmin handles ADMIN. +func (hdlr *Handlers) handleAdmin( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, +) { + ctx := request.Context() + srvName := hdlr.serverName() + location1, location2, email := hdlr.svc.AdminLines(srvName) + + hdlr.enqueueNumeric( + ctx, clientID, irc.RplAdminMe, nick, + []string{srvName}, "Administrative info", + ) + hdlr.enqueueNumeric( + ctx, clientID, irc.RplAdminLoc1, nick, nil, location1, + ) + hdlr.enqueueNumeric( + ctx, clientID, irc.RplAdminLoc2, nick, nil, location2, + ) + hdlr.enqueueNumeric( + ctx, clientID, irc.RplAdminEmail, nick, nil, email, + ) + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{statusKey: "ok"}, + http.StatusOK) +} + +// handleInfo handles INFO. +func (hdlr *Handlers) handleInfo( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, +) { + ctx := request.Context() + + for _, line := range hdlr.svc.InfoLines() { + hdlr.enqueueNumeric( + ctx, clientID, irc.RplInfo, nick, nil, line, + ) + } + + hdlr.enqueueNumeric( + ctx, clientID, irc.RplEndOfInfo, nick, nil, + "End of /INFO list", + ) + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{statusKey: "ok"}, + http.StatusOK) +} + +// handleTime handles TIME: the server's local time. +func (hdlr *Handlers) handleTime( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, +) { + hdlr.enqueueNumeric( + request.Context(), clientID, irc.RplTime, nick, + []string{hdlr.serverName()}, + time.Now().Format(time.RFC1123), + ) + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{statusKey: "ok"}, + http.StatusOK) +} + +// handleKill handles KILL: the body is the target nick and +// an optional reason. +func (hdlr *Handlers) handleKill( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, + bodyLines func() []string, +) { + lines := bodyLines() + + targetNick := "" + if len(lines) > 0 { + targetNick = strings.TrimSpace(lines[0]) + } + + if targetNick == "" { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNeedMoreParams, nick, + []string{irc.CmdKill}, + "Not enough parameters", + ) + + return + } + + reason := "" + if len(lines) > 1 { + reason = lines[1] + } + + err := hdlr.svc.KillUser( + request.Context(), sessionID, nick, + targetNick, reason, + ) + if hdlr.handleServiceError( + writer, request, clientID, sessionID, nick, err, + ) { + return + } + + hdlr.respondJSON(writer, request, + map[string]string{statusKey: "ok"}, + http.StatusOK) +} + +// handleWallops handles WALLOPS: the body is the message. +func (hdlr *Handlers) handleWallops( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, + bodyLines func() []string, +) { + lines := bodyLines() + if len(lines) == 0 { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNeedMoreParams, nick, + []string{irc.CmdWallops}, + "Not enough parameters", + ) + + return + } + + err := hdlr.svc.SendWallops( + request.Context(), sessionID, nick, + strings.Join(lines, " "), + ) + if hdlr.handleServiceError( + writer, request, clientID, sessionID, nick, err, + ) { + return + } + + hdlr.respondJSON(writer, request, + map[string]string{statusKey: "ok"}, + http.StatusOK) +} + +// handleUserMode handles MODE for a nick: a query when the +// body is empty, otherwise a change by the mode string in +// the body. Only the caller's own nick, in any letter case, +// is allowed. +func (hdlr *Handlers) handleUserMode( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, target string, + bodyLines func() []string, +) { + if !strings.EqualFold(target, nick) { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrUsersDoNotMatch, nick, nil, + "Can't change mode for other users", + ) + + return + } + + ctx := request.Context() + + var ( + modes string + err error + ) + + lines := bodyLines() + if len(lines) > 0 { + modes, err = hdlr.svc.ApplyUserMode( + ctx, sessionID, lines[0], + ) + } else { + modes, err = hdlr.svc.QueryUserMode(ctx, sessionID) + } + + if hdlr.handleServiceError( + writer, request, clientID, sessionID, nick, err, + ) { + return + } + + hdlr.enqueueNumeric( + ctx, clientID, irc.RplUmodeIs, nick, nil, modes, + ) + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{statusKey: "ok"}, + http.StatusOK) +} diff --git a/internal/handlers/utility_test.go b/internal/handlers/utility_test.go new file mode 100644 index 0000000..7afa834 --- /dev/null +++ b/internal/handlers/utility_test.go @@ -0,0 +1,335 @@ +//nolint:paralleltest // global viper, as in api_test.go +package handlers_test + +import ( + "net/http" + "strings" + "testing" + + "sneak.berlin/go/neoirc/pkg/irc" +) + +// Each test starts one server and reuses it for all its +// checks, and makes as few requests as it can: servers, +// sessions and requests are what makes this package's tests +// slow. + +// sendAndPoll sends cmd from the session token and returns +// the messages after lastID that the session has been sent, +// and the ID of the last of them. +func sendAndPoll( + tserver *testServer, + token string, + lastID int64, + cmd map[string]any, +) ([]map[string]any, int64) { + tserver.t.Helper() + + tserver.sendCommand(token, cmd) + + return tserver.pollMessages(token, lastID) +} + +// newOperSession creates a session for nick, makes it a +// server operator, and returns its token and the ID of the +// last message it has been sent. +func newOperSession( + tserver *testServer, nick string, +) (string, int64) { + tserver.t.Helper() + + token := tserver.createSession(nick) + + _, lastID := sendAndPoll(tserver, token, 0, map[string]any{ + commandKey: irc.CmdOper, + bodyKey: []string{testOperName, testOperPassword}, + }) + + return token, lastID +} + +// numericBody returns the first body line of the message in +// msgs with the given numeric, and fails the test if there +// is none. +func numericBody( + t *testing.T, msgs []map[string]any, numeric string, +) string { + t.Helper() + + msg := findNumericWithParams(msgs, numeric) + if msg == nil { + t.Fatalf("expected numeric %s, got %v", numeric, msgs) + } + + lines, _ := msg[bodyKey].([]any) + if len(lines) == 0 { + return "" + } + + line, _ := lines[0].(string) + + return line +} + +func TestUserhost(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("alice") + tserver.createSession("bob") + + _, lastID := tserver.pollMessages(token, 0) + + msgs, lastID := sendAndPoll(tserver, token, lastID, map[string]any{ + commandKey: irc.CmdUserhost, + bodyKey: []string{"alice", "bob"}, + }) + + body := numericBody(t, msgs, "302") + if !strings.HasPrefix(body, "alice=+alice@") || + !strings.Contains(body, " bob=+bob@") { + t.Errorf("expected alice and bob, got %q", body) + } + + msgs, lastID = sendAndPoll(tserver, token, lastID, map[string]any{ + commandKey: irc.CmdUserhost, + bodyKey: []string{"ghost"}, + }) + + body = numericBody(t, msgs, "302") + if body != "" { + t.Errorf("expected an empty reply for ghost, got %q", body) + } + + msgs, _ = sendAndPoll(tserver, token, lastID, map[string]any{ + commandKey: irc.CmdUserhost, + }) + if !findNumeric(msgs, "461") { + t.Errorf("expected ERR_NEEDMOREPARAMS (461), got %v", msgs) + } +} + +func TestVersionAdminInfoTime(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("frank") + _, lastID := tserver.pollMessages(token, 0) + + msgs, lastID := sendAndPoll(tserver, token, lastID, map[string]any{ + commandKey: irc.CmdVersion, + }) + + params := getNumericParams(findNumericWithParams(msgs, "351")) + if len(params) == 0 || params[0] != "neoirc-test-test." { + t.Errorf("expected RPL_VERSION neoirc-test-test., got %v", msgs) + } + + for _, check := range []struct { + command string + numerics []string + }{ + {irc.CmdAdmin, []string{"256", "257", "258", "259"}}, + {irc.CmdInfo, []string{"371", "374"}}, + {irc.CmdTime, []string{"391"}}, + } { + msgs, lastID = sendAndPoll(tserver, token, lastID, map[string]any{ + commandKey: check.command, + }) + + for _, numeric := range check.numerics { + if !findNumeric(msgs, numeric) { + t.Errorf( + "%s: expected %s, got %v", + check.command, numeric, msgs, + ) + } + } + } +} + +func TestKill(t *testing.T) { + tserver := newTestServerWithOper(t) + + victimToken := tserver.createSession("victim") + observerToken := tserver.createSession("observer") + notOperToken := tserver.createSession("notoper") + operToken, operLastID := newOperSession(tserver, "killer") + + for _, token := range []string{victimToken, observerToken} { + tserver.sendCommand(token, map[string]any{ + commandKey: joinCmd, + toKey: "#killtest", + }) + } + + _, notOperLastID := tserver.pollMessages(notOperToken, 0) + + msgs, _ := sendAndPoll(tserver, notOperToken, notOperLastID, map[string]any{ + commandKey: irc.CmdKill, + bodyKey: []string{"victim"}, + }) + if !findNumeric(msgs, "481") { + t.Errorf("expected ERR_NOPRIVILEGES (481), got %v", msgs) + } + + for _, check := range []struct { + numeric string + body []string + }{ + {"461", []string{}}, + {"401", []string{"ghost"}}, + {"483", []string{"killer"}}, + } { + msgs, operLastID = sendAndPoll(tserver, operToken, operLastID, map[string]any{ + commandKey: irc.CmdKill, + bodyKey: check.body, + }) + if !findNumeric(msgs, check.numeric) { + t.Errorf( + "KILL %v: expected %s, got %v", + check.body, check.numeric, msgs, + ) + } + } + + _, observerLastID := tserver.pollMessages(observerToken, 0) + + status, result := tserver.sendCommand(operToken, map[string]any{ + commandKey: irc.CmdKill, + bodyKey: []string{"victim", "go away"}, + }) + if status != http.StatusOK || result[statusKey] != "ok" { + t.Fatalf("expected 200 and status ok, got %d: %v", status, result) + } + + msgs, _ = tserver.pollMessages(observerToken, observerLastID) + if !findMessage(msgs, irc.CmdQuit, "victim") { + t.Errorf("expected the observer to see QUIT, got %v", msgs) + } + + status, _ = tserver.getState(victimToken) + if status != http.StatusUnauthorized { + t.Errorf("expected the victim's session gone, got %d", status) + } +} + +func TestWallops(t *testing.T) { + tserver := newTestServerWithOper(t) + + receiverToken := tserver.createSession("receiver") + plainToken := tserver.createSession("plain") + operToken, operLastID := newOperSession(tserver, "walloper") + + _, receiverLastID := sendAndPoll(tserver, receiverToken, 0, map[string]any{ + commandKey: modeCmd, + toKey: "receiver", + bodyKey: []string{"+w"}, + }) + _, plainLastID := tserver.pollMessages(plainToken, 0) + + msgs, plainLastID := sendAndPoll(tserver, plainToken, plainLastID, map[string]any{ + commandKey: irc.CmdWallops, + bodyKey: []string{"not allowed"}, + }) + if !findNumeric(msgs, "481") { + t.Errorf("expected ERR_NOPRIVILEGES (481), got %v", msgs) + } + + msgs, _ = sendAndPoll(tserver, operToken, operLastID, map[string]any{ + commandKey: irc.CmdWallops, + }) + if !findNumeric(msgs, "461") { + t.Errorf("expected ERR_NEEDMOREPARAMS (461), got %v", msgs) + } + + tserver.sendCommand(operToken, map[string]any{ + commandKey: irc.CmdWallops, + bodyKey: []string{"server going down"}, + }) + + msgs, _ = tserver.pollMessages(receiverToken, receiverLastID) + if !findMessage(msgs, irc.CmdWallops, "walloper") { + t.Errorf("expected WALLOPS for the +w user, got %v", msgs) + } + + msgs, _ = tserver.pollMessages(plainToken, plainLastID) + if findMessage(msgs, irc.CmdWallops, "walloper") { + t.Errorf("WALLOPS reached a user without +w: %v", msgs) + } +} + +func TestUserMode(t *testing.T) { + const ( + nick = "alice" + other = "other" + ) + + tserver := newTestServerWithOper(t) + + token := tserver.createSession(nick) + otherToken := tserver.createSession(other) + operToken, operLastID := newOperSession(tserver, "deoper") + + // mode sends MODE for target from token, with modeStr + // unless it is empty, and returns the session's next + // messages. + mode := func( + token string, lastID int64, target, modeStr string, + ) ([]map[string]any, int64) { + cmd := map[string]any{commandKey: modeCmd, toKey: target} + if modeStr != "" { + cmd[bodyKey] = []string{modeStr} + } + + return sendAndPoll(tserver, token, lastID, cmd) + } + + _, otherLastID := mode(otherToken, 0, other, "+w") + _, lastID := tserver.pollMessages(token, 0) + + for _, check := range []struct { + target, modeStr, numeric, modes string + }{ + {nick, "+w", "221", "+w"}, + {nick, "", "221", "+w"}, + {strings.ToUpper(nick), "-w", "221", "+"}, + {nick, "+z", "501", ""}, + {nick, "+o", "501", ""}, + {other, "", "502", ""}, + {other, "-w", "502", ""}, + } { + var msgs []map[string]any + + msgs, lastID = mode(token, lastID, check.target, check.modeStr) + + if check.numeric != "221" { + if !findNumeric(msgs, check.numeric) || + findNumeric(msgs, "221") { + t.Errorf( + "MODE %s %s: expected only %s, got %v", + check.target, check.modeStr, + check.numeric, msgs, + ) + } + + continue + } + + got := numericBody(t, msgs, "221") + if got != check.modes { + t.Errorf( + "MODE %s %s: expected %q, got %q", + check.target, check.modeStr, check.modes, got, + ) + } + } + + msgs, _ := mode(otherToken, otherLastID, other, "") + if got := numericBody(t, msgs, "221"); got != "+w" { + t.Errorf("%s's modes changed to %q", other, got) + } + + msgs, _ = mode(operToken, operLastID, "deoper", "-o") + if got := numericBody(t, msgs, "221"); got != "+" { + t.Errorf("after -o: expected +, got %q", got) + } +} diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index e6c3356..b5b0d75 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -13,7 +13,7 @@ import ( ) // sendIRCError maps a service.IRCError to an IRC numeric -// reply on the wire. +// reply on the wire, and logs any other error. func (c *Conn) sendIRCError(err error) { var ircErr *service.IRCError if errors.As(err, &ircErr) { @@ -21,7 +21,11 @@ func (c *Conn) sendIRCError(err error) { args = append(args, ircErr.Params...) args = append(args, ircErr.Message) c.sendNumeric(ircErr.Code, args...) + + return } + + c.log.Error("command failed", "error", err) } // handleCAP silently acknowledges CAP negotiation. @@ -345,7 +349,10 @@ func (c *Conn) handleQuit(msg *Message) { c.send("ERROR :Closing Link: " + c.hostname + " (Quit: " + reason + ")") + + c.mu.Lock() c.closed = true + c.mu.Unlock() } // handleTopic gets or sets a channel topic via the shared @@ -427,7 +434,7 @@ func (c *Conn) handleMode( if strings.HasPrefix(target, "#") { c.handleChannelMode(ctx, msg) } else { - c.handleUserMode(msg) + c.handleUserMode(ctx, msg) } } @@ -686,11 +693,14 @@ func (c *Conn) applyChannelModes( } } -// handleUserMode handles MODE for users. -func (c *Conn) handleUserMode(msg *Message) { - target := msg.Params[0] - - if !strings.EqualFold(target, c.nick) { +// handleUserMode handles MODE for a nick: a query without a +// mode string, otherwise a change. Only the client's own +// nick, in any letter case, is allowed. +func (c *Conn) handleUserMode( + ctx context.Context, + msg *Message, +) { + if !strings.EqualFold(msg.Params[0], c.currentNick()) { c.sendNumeric( irc.ErrUsersDoNotMatch, "Can't change mode for other users", @@ -699,8 +709,26 @@ func (c *Conn) handleUserMode(msg *Message) { return } - // We don't support user modes beyond the basics. - c.sendNumeric(irc.RplUmodeIs, "+") + var ( + modes string + err error + ) + + 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.sendIRCError(err) + + return + } + + c.sendNumeric(irc.RplUmodeIs, modes) } // handleNames replies with channel member list. @@ -1248,47 +1276,116 @@ func (c *Conn) handleInvite( ) } -// handleUserhost replies with USERHOST info. +// handleUserhost replies with user@host for each of up to +// five nicks. func (c *Conn) handleUserhost( ctx context.Context, msg *Message, ) { if len(msg.Params) < 1 { + c.sendNumeric( + irc.ErrNeedMoreParams, + irc.CmdUserhost, "Not enough parameters", + ) + return } - replies := make([]string, 0, len(msg.Params)) + reply, err := c.svc.UserhostReply( + ctx, msg.Params, c.serverSfx, + ) + if err != nil { + c.sendIRCError(err) - for _, nick := range msg.Params { - sid, err := c.database.GetSessionByNick(ctx, nick) - if err != nil { - continue - } - - hostInfo, _ := c.database.GetSessionHostInfo( - ctx, sid, - ) - - host := "*" - if hostInfo != nil { - host = hostInfo.Hostname - } - - isOper, _ := c.database.IsSessionOper(ctx, sid) - - operStar := "" - if isOper { - operStar = "*" - } - - replies = append( - replies, - nick+operStar+"=+"+nick+"@"+host, - ) + return } + c.sendNumeric(irc.RplUserHost, reply) +} + +// handleVersion replies with the server version. +func (c *Conn) handleVersion() { c.sendNumeric( - irc.RplUserHost, - strings.Join(replies, " "), + irc.RplVersion, + c.svc.ServerVersion()+".", c.serverSfx, "", ) } + +// handleAdmin replies with the server's admin info. +func (c *Conn) handleAdmin() { + location1, location2, email := c.svc.AdminLines(c.serverSfx) + + c.sendNumeric( + irc.RplAdminMe, c.serverSfx, "Administrative info", + ) + c.sendNumeric(irc.RplAdminLoc1, location1) + c.sendNumeric(irc.RplAdminLoc2, location2) + c.sendNumeric(irc.RplAdminEmail, email) +} + +// handleInfo replies with the server's software info. +func (c *Conn) handleInfo() { + for _, line := range c.svc.InfoLines() { + 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( + irc.RplTime, + c.serverSfx, time.Now().Format(time.RFC1123), + ) +} + +// handleKill handles KILL []. +func (c *Conn) handleKill( + ctx context.Context, + msg *Message, +) { + if len(msg.Params) < 1 { + c.sendNumeric( + irc.ErrNeedMoreParams, + irc.CmdKill, "Not enough parameters", + ) + + return + } + + reason := "" + if len(msg.Params) > 1 { + reason = msg.Params[1] + } + + err := c.svc.KillUser( + ctx, c.sessionID, c.currentNick(), + msg.Params[0], reason, + ) + if err != nil { + c.sendIRCError(err) + } +} + +// handleWallops handles WALLOPS . +func (c *Conn) handleWallops( + ctx context.Context, + msg *Message, +) { + if len(msg.Params) < 1 { + c.sendNumeric( + irc.ErrNeedMoreParams, + irc.CmdWallops, "Not enough parameters", + ) + + return + } + + err := c.svc.SendWallops( + ctx, c.sessionID, c.currentNick(), msg.Params[0], + ) + if err != nil { + c.sendIRCError(err) + } +} diff --git a/internal/ircserver/conn.go b/internal/ircserver/conn.go index fdd6b35..74e4dd1 100644 --- a/internal/ircserver/conn.go +++ b/internal/ircserver/conn.go @@ -22,6 +22,7 @@ const ( maxLineLen = 512 readTimeout = 5 * time.Minute writeTimeout = 30 * time.Second + killWriteWindow = 2 * time.Second dnsTimeout = 3 * time.Second pollInterval = 100 * time.Millisecond pingInterval = 90 * time.Second @@ -46,6 +47,11 @@ type Conn struct { serverSfx string commands map[string]cmdHandler + // writeMu serializes writes to conn, which come from + // serve(), from the relay goroutine and, for KILL, from + // Disconnect. + writeMu sync.Mutex + mu sync.Mutex nick string username string @@ -62,6 +68,7 @@ type Conn struct { lastQueueID int64 closed bool + killed bool } func newConn( @@ -97,6 +104,57 @@ func newConn( return conn } +// Disconnect ends the connection for an operator KILL from +// either transport: the victim is sent KILL and ERROR, then +// its socket is closed, which ends serve() and with it the +// relay goroutine. It is called from the killer's goroutine +// and does not wait for the writes, so a victim that has +// stopped reading cannot stall the killer. +func (c *Conn) Disconnect(reason string) { + c.mu.Lock() + + if c.closed { + c.mu.Unlock() + + return + } + + c.closed = true + c.killed = true + nick := c.nick + host := c.hostname + c.mu.Unlock() + + if nick == "" { + nick = "*" + } + + go func() { + c.notifyKilledAndClose(nick, host, reason) + }() +} + +// notifyKilledAndClose sends a killed victim the KILL and +// ERROR lines, each bounded by killWriteWindow, and then +// closes its socket. +func (c *Conn) notifyKilledAndClose( + nick, host, reason string, +) { + defer func() { _ = c.conn.Close() }() + + c.sendWithin( + killWriteWindow, + FormatMessage( + c.serverSfx, irc.CmdKill, nick, reason, + ), + ) + c.sendWithin( + killWriteWindow, + "ERROR :Closing Link: "+host+ + " ("+reason+")", + ) +} + // buildCommandMap returns a map from IRC command strings // to handler functions. func (c *Conn) buildCommandMap() map[string]cmdHandler { @@ -129,7 +187,13 @@ func (c *Conn) buildCommandMap() map[string]cmdHandler { "CAP": func(_ context.Context, msg *Message) { c.handleCAP(msg) }, - "USERHOST": c.handleUserhost, + irc.CmdUserhost: c.handleUserhost, + irc.CmdVersion: func(context.Context, *Message) { c.handleVersion() }, + irc.CmdAdmin: func(context.Context, *Message) { c.handleAdmin() }, + irc.CmdInfo: func(context.Context, *Message) { c.handleInfo() }, + irc.CmdTime: func(context.Context, *Message) { c.handleTime() }, + irc.CmdKill: c.handleKill, + irc.CmdWallops: c.handleWallops, } } @@ -180,7 +244,7 @@ func (c *Conn) serve(ctx context.Context) { c.handleMessage(ctx, msg) - if c.closed { + if c.isClosed() { return } } @@ -189,24 +253,60 @@ func (c *Conn) serve(ctx context.Context) { func (c *Conn) cleanup(ctx context.Context) { c.mu.Lock() wasRegistered := c.registered + wasKilled := c.killed sessID := c.sessionID nick := c.nick c.closed = true c.mu.Unlock() if wasRegistered && sessID > 0 { - c.svc.BroadcastQuit( - ctx, sessID, nick, "Connection closed", - ) + c.svc.UnregisterWireConn(sessID, c) + + // KILL has already sent the QUIT and deleted the + // session. + if !wasKilled { + c.svc.BroadcastQuit( + ctx, sessID, nick, "Connection closed", + ) + } } c.conn.Close() //nolint:errcheck,gosec } +// isClosed reports whether QUIT or KILL has ended the +// connection. +func (c *Conn) isClosed() bool { + c.mu.Lock() + defer c.mu.Unlock() + + return c.closed +} + +// currentNick returns c.nick, read under c.mu. +func (c *Conn) currentNick() string { + c.mu.Lock() + defer c.mu.Unlock() + + return c.nick +} + // send writes a formatted IRC line to the connection. func (c *Conn) send(line string) { + c.sendWithin(writeTimeout, line) +} + +// sendWithin writes a formatted IRC line to the connection, +// giving up after timeout. +func (c *Conn) sendWithin( + timeout time.Duration, + line string, +) { + c.writeMu.Lock() + defer c.writeMu.Unlock() + _ = c.conn.SetWriteDeadline( - time.Now().Add(writeTimeout), + time.Now().Add(timeout), ) _, _ = fmt.Fprintf(c.conn, "%s\r\n", line) @@ -387,7 +487,10 @@ func (c *Conn) completeRegistration(ctx context.Context) { "failed to create session", "error", err, ) c.send("ERROR :Internal server error") + + c.mu.Lock() c.closed = true + c.mu.Unlock() return } @@ -398,6 +501,9 @@ func (c *Conn) completeRegistration(ctx context.Context) { c.registered = true c.mu.Unlock() + // So that KILL, from either transport, can close it. + c.svc.RegisterWireConn(sessionID, c) + // If PASS was provided before registration, set the // session password. if c.passWord != "" && len(c.passWord) >= minPasswordLen { diff --git a/internal/ircserver/conn_test.go b/internal/ircserver/conn_test.go new file mode 100644 index 0000000..d860d81 --- /dev/null +++ b/internal/ircserver/conn_test.go @@ -0,0 +1,102 @@ +package ircserver_test + +import ( + "bufio" + "log/slog" + "net" + "os" + "strings" + "testing" + "time" + + "sneak.berlin/go/neoirc/internal/config" + "sneak.berlin/go/neoirc/internal/ircserver" +) + +// newKillVictim returns a Conn for the nick victim whose +// socket is one end of a net.Pipe, and the other end, from +// which the test reads what the victim is sent. A write to +// the pipe blocks until the other end reads. +func newKillVictim(t *testing.T) (*ircserver.Conn, net.Conn) { + t.Helper() + + serverSide, clientSide := net.Pipe() + + t.Cleanup(func() { _ = clientSide.Close() }) + + log := slog.New(slog.NewTextHandler( + os.Stderr, + &slog.HandlerOptions{Level: slog.LevelError}, + )) + 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( + t *testing.T, +) { + t.Parallel() + + victim, _ := newKillVictim(t) + returned := make(chan struct{}) + + go func() { + victim.Disconnect("killed by oper") + close(returned) + }() + + select { + case <-returned: + case <-time.After(ircserver.KillWriteWindow / 4): + t.Fatal("Disconnect waited for the victim to read") + } +} + +// TestDisconnectNotifiesAndClosesVictim checks that a +// victim that reads is sent KILL and ERROR, and that its +// connection is then closed. +func TestDisconnectNotifiesAndClosesVictim(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("killed by oper") + + var lines []string + + select { + case lines = <-received: + case <-time.After(testTimeout): + t.Fatal("the victim's connection was not closed") + } + + joined := strings.Join(lines, "\n") + + if !strings.Contains(joined, "KILL victim :killed by oper") { + t.Errorf("missing KILL line, got: %q", joined) + } + + if !strings.Contains(joined, "ERROR :Closing Link:") { + t.Errorf("missing ERROR line, got: %q", joined) + } +} diff --git a/internal/ircserver/export_test.go b/internal/ircserver/export_test.go index 84e870b..4e665b2 100644 --- a/internal/ircserver/export_test.go +++ b/internal/ircserver/export_test.go @@ -4,10 +4,12 @@ import ( "context" "log/slog" "net" + "time" "sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/db" + "sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/service" ) @@ -19,8 +21,14 @@ func NewTestServer( database *db.Database, brk *broker.Broker, ) *Server { + globs := &globals.Globals{ + Appname: "neoirc", + Version: "test", + StartTime: time.Now(), + } + svc := service.NewTestService( - database, brk, cfg, log, + database, brk, cfg, globs, log, ) return &Server{ //nolint:exhaustruct @@ -47,3 +55,24 @@ func (s *Server) Stop() { func (s *Server) Listener() net.Listener { return s.listener } + +// KillWriteWindow is how long Disconnect allows each write +// to a killed victim. +const KillWriteWindow = killWriteWindow + +// NewTestConn returns a Conn for nick on tcpConn, without a +// server, database or service behind it. +func NewTestConn( + log *slog.Logger, + cfg *config.Config, + tcpConn net.Conn, + nick string, +) *Conn { + conn := newConn( + context.Background(), tcpConn, log, + nil, nil, cfg, nil, + ) + conn.nick = nick + + return conn +} diff --git a/internal/ircserver/integration_test.go b/internal/ircserver/integration_test.go index 57cd413..adb69f4 100644 --- a/internal/ircserver/integration_test.go +++ b/internal/ircserver/integration_test.go @@ -4,6 +4,8 @@ import ( "strings" "testing" "time" + + "sneak.berlin/go/neoirc/internal/config" ) // TestIntegrationTwoClients is a comprehensive integration @@ -762,6 +764,371 @@ func TestIntegrationTwoClients(t *testing.T) { ) } +// ── Tier 3 Utility Command Integration Tests ────────── + +// TestIntegrationUserhost verifies the USERHOST command +// returns user@host info for connected nicks. +func TestIntegrationUserhost(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + alice := env.dial(t) + alice.register("alice") + + bob := env.dial(t) + bob.register("bob") + + bob.send("AWAY :lunch") + bob.readUntil(func(l string) bool { + return strings.Contains(l, " 306 ") + }) + + for _, step := range []struct { + line, want string + }{ + {"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 { + return strings.Contains(l, " 302 ") || + strings.Contains(l, " 461 ") + }) + assertContains(t, reply, step.want, step.line) + } +} + +// TestIntegrationVersion verifies the VERSION command +// returns the server version string. +func TestIntegrationVersion(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + alice := env.dial(t) + alice.register("alice") + + alice.send("VERSION") + + aliceReply := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 351 ") + }) + assertContains( + t, aliceReply, " 351 ", + "RPL_VERSION", + ) + assertContains( + t, aliceReply, "neoirc", + "VERSION reply contains server name", + ) +} + +// TestIntegrationAdmin verifies the ADMIN command returns +// server admin info (256–259 numerics). +func TestIntegrationAdmin(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + alice := env.dial(t) + alice.register("alice") + + alice.send("ADMIN") + + aliceReply := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 259 ") + }) + assertContains( + t, aliceReply, " 256 ", + "RPL_ADMINME", + ) + assertContains( + t, aliceReply, " 257 ", + "RPL_ADMINLOC1", + ) + assertContains( + t, aliceReply, " 258 ", + "RPL_ADMINLOC2", + ) + assertContains( + t, aliceReply, " 259 ", + "RPL_ADMINEMAIL", + ) +} + +// TestIntegrationInfo verifies the INFO command returns +// server information (371/374 numerics). +func TestIntegrationInfo(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + alice := env.dial(t) + alice.register("alice") + + alice.send("INFO") + + aliceReply := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 374 ") + }) + assertContains( + t, aliceReply, " 371 ", + "RPL_INFO", + ) + assertContains( + t, aliceReply, " 374 ", + "RPL_ENDOFINFO", + ) + assertContains( + t, aliceReply, "neoirc", + "INFO reply mentions server name", + ) +} + +// TestIntegrationTime verifies the TIME command returns +// the server time (391 numeric). +func TestIntegrationTime(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + alice := env.dial(t) + alice.register("alice") + + alice.send("TIME") + + aliceReply := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 391 ") + }) + assertContains( + t, aliceReply, " 391 ", + "RPL_TIME", + ) + assertContains( + t, aliceReply, testServerName, + "TIME reply includes server name", + ) +} + +// becomeOper sends OPER with newTestEnvWithOper's +// credentials and waits for RPL_YOUREOPER. +func (tc *testClient) becomeOper() { + tc.t.Helper() + + tc.send("OPER testoper testpass") + 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) { + t.Parallel() + + env := newTestEnvWithOper(t) + + alice := env.dial(t) + alice.register("alice") + + bob := env.dial(t) + bob.register("bob") + + alice.joinAndDrain("#killtest") + bob.joinAndDrain("#killtest") + + // Drain alice's view of bob's join. + alice.readUntil(func(l string) bool { + return strings.Contains(l, "JOIN") && + strings.Contains(l, "bob") + }) + + alice.becomeOper() + alice.send("KILL bob :bad behavior") + + bobLines := bob.readUntilClosed() + assertContains( + t, bobLines, "KILL", + "victim receives KILL before disconnect", + ) + assertContains( + t, bobLines, "ERROR :Closing Link", + "victim receives ERROR before disconnect", + ) + assertContains( + t, bobLines, "bad behavior", + "KILL reason delivered to victim", + ) + + // alice should see bob's QUIT relay. + aliceSeesQuit := alice.readUntil(func(l string) bool { + return strings.Contains(l, "QUIT") && + strings.Contains(l, "bob") + }) + assertContains( + t, aliceSeesQuit, "Killed", + "KILL reason in QUIT message", + ) + + // bob must be gone from the channel member list. + alice.send("NAMES #killtest") + + aliceNames := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 366 ") + }) + assertContains( + t, aliceNames, "alice", + "alice still in NAMES after killing bob", + ) + assertNotContains( + t, aliceNames, "bob", + "killed user must not appear in NAMES", + ) + + // ...nor from WHO. + alice.send("WHO #killtest") + + aliceWho := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 315 ") + }) + assertNotContains( + t, aliceWho, "bob", + "killed user must not appear in WHO", + ) +} + +// TestIntegrationWallops verifies the WALLOPS command: +// oper can broadcast to +w users. +func TestIntegrationWallops(t *testing.T) { + t.Parallel() + + env := newTestEnvWithOper(t) + + alice := env.dial(t) + alice.register("alice") + + bob := env.dial(t) + bob.register("bob") + + // Non-oper WALLOPS should fail. + alice.send("WALLOPS :test broadcast") + + aliceWallopsFail := alice.readUntil(func(l string) bool { + return strings.Contains(l, " 481 ") + }) + assertContains( + t, aliceWallopsFail, " 481 ", + "ERR_NOPRIVILEGES for non-oper WALLOPS", + ) + + alice.becomeOper() + + // bob sets +w to receive wallops. + bob.send("MODE bob +w") + bob.readUntil(func(l string) bool { + return strings.Contains(l, " 221 ") + }) + + // alice sends WALLOPS. + alice.send("WALLOPS :important announcement") + + // bob (who has +w) should receive it. + bobWallops := bob.readUntil(func(l string) bool { + return strings.Contains( + l, "important announcement", + ) + }) + assertContains( + t, bobWallops, "important announcement", + "bob receives WALLOPS message", + ) + assertContains( + t, bobWallops, "WALLOPS", + "message is WALLOPS command", + ) +} + +// TestIntegrationUserMode covers MODE for a nick: changes +// and queries of your own modes in any letter case, refusal +// for another nick, 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 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 // mode — verifies that +s can be set and the mode is // reflected in MODE queries. @@ -915,3 +1282,30 @@ func TestIntegrationThirdClientObserver(t *testing.T) { "carol receives trio message", ) } + +// TestIntegrationDefaultServerNameFallback checks that with +// SERVER_NAME unset, as it is by default, VERSION, ADMIN and +// TIME name the server "neoirc". +func TestIntegrationDefaultServerNameFallback(t *testing.T) { + t.Parallel() + + env := newTestEnvWithConfig(t, &config.Config{}) + + alice := env.dial(t) + alice.register("alice") + + for _, step := range []struct { + 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 { + return strings.Contains(l, step.lastNumeric) + }) + assertContains(t, reply, step.want, step.line) + } +} diff --git a/internal/ircserver/relay.go b/internal/ircserver/relay.go index 6d4195f..60ccb74 100644 --- a/internal/ircserver/relay.go +++ b/internal/ircserver/relay.go @@ -120,6 +120,8 @@ func (c *Conn) deliverIRCMessage( c.deliverKickMsg(msg, text) case command == "INVITE": c.deliverInviteMsg(msg, text) + case command == irc.CmdWallops: + c.deliverWallops(msg, text) case command == irc.CmdMode: c.deliverMode(msg, text) case command == irc.CmdPing: @@ -337,6 +339,18 @@ func (c *Conn) deliverInviteMsg( c.sendFromServer("NOTICE", nick, text) } +// deliverWallops sends a WALLOPS notification. +func (c *Conn) deliverWallops( + msg *db.IRCMessage, + text string, +) { + prefix := msg.From + "!" + msg.From + "@*" + + c.send(FormatMessage( + prefix, irc.CmdWallops, text, + )) +} + // deliverMode sends a MODE change notification. func (c *Conn) deliverMode( msg *db.IRCMessage, diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index 0ea6f4e..3717474 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -36,9 +36,39 @@ type testEnv struct { srv *ircserver.Server } +// testServerName is the SERVER_NAME of newTestEnv. +const testServerName = "test.irc" + func newTestEnv(t *testing.T) *testEnv { t.Helper() + return newTestEnvWithConfig(t, &config.Config{ + ServerName: testServerName, + MOTD: "Welcome to test IRC", + }) +} + +// newTestEnvWithOper is newTestEnv with the operator name +// testoper and password testpass. +func newTestEnvWithOper(t *testing.T) *testEnv { + t.Helper() + + return newTestEnvWithConfig(t, &config.Config{ + 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, + cfg *config.Config, +) *testEnv { + t.Helper() + dsn := fmt.Sprintf( "file:%s?mode=memory&cache=shared&_journal_mode=WAL", t.Name(), @@ -67,11 +97,6 @@ func newTestEnv(t *testing.T) *testEnv { brk := broker.New() - cfg := &config.Config{ //nolint:exhaustruct - ServerName: "test.irc", - MOTD: "Welcome to test IRC", - } - var listenConfig net.ListenConfig listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0") @@ -215,6 +240,33 @@ func (tc *testClient) register(nick string) []string { }) } +// readUntilClosed returns the lines received until the +// server closes the connection, and fails the test if it is +// still open after testTimeout. +func (tc *testClient) readUntilClosed() []string { + tc.t.Helper() + + _ = tc.conn.SetReadDeadline( + time.Now().Add(testTimeout), + ) + + var lines []string + + for tc.scanner.Scan() { + lines = append(lines, tc.scanner.Text()) + } + + err := tc.scanner.Err() + if err != nil { + tc.t.Fatalf( + "connection not closed: %v (lines: %v)", + err, lines, + ) + } + + return lines +} + // assertContains checks that at least one line matches the // given substring. func assertContains( @@ -233,6 +285,27 @@ func assertContains( t.Errorf("did not find %q in output: %s", substr, description) } +// assertNotContains checks that no line matches the given +// substring. +func assertNotContains( + t *testing.T, + lines []string, + substr, description string, +) { + t.Helper() + + for _, line := range lines { + if strings.Contains(line, substr) { + t.Errorf( + "unexpectedly found %q in output: %s", + substr, description, + ) + + return + } + } +} + // joinAndDrain joins a channel and reads until // RPL_ENDOFNAMES. func (tc *testClient) joinAndDrain(channel string) { diff --git a/internal/service/service.go b/internal/service/service.go index 690ce08..361bcd3 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -5,16 +5,21 @@ package service import ( "context" "crypto/subtle" + "database/sql" "encoding/json" + "errors" "fmt" "log/slog" "strconv" "strings" + "sync" + "time" "go.uber.org/fx" "sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/db" + "sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/pkg/irc" ) @@ -22,9 +27,14 @@ import ( // Error texts that several commands reply with. const ( msgNoSuchChannel = "No such channel" + msgNoSuchNick = "No such nick/channel" msgNotChannelOp = "You're not channel operator" ) +// maxUserhostNicks is how many nicks one USERHOST answers +// for (RFC 2812). +const maxUserhostNicks = 5 + // Params defines the dependencies for creating a Service. type Params struct { fx.In @@ -33,23 +43,38 @@ type Params struct { Config *config.Config Database *db.Database Broker *broker.Broker + Globals *globals.Globals +} + +// WireConn is a registered IRC connection, which KILL closes. +// HTTP clients hold no connection and register none. +type WireConn interface { + // Disconnect tells the client why and closes the + // connection. + Disconnect(reason string) } // Service provides shared business logic for IRC commands. type Service struct { - db *db.Database - broker *broker.Broker - config *config.Config - log *slog.Logger + db *db.Database + broker *broker.Broker + config *config.Config + globals *globals.Globals + log *slog.Logger + + wireMu sync.Mutex + wireConns map[int64]WireConn } // New creates a new Service. func New(params Params) *Service { return &Service{ - db: params.Database, - broker: params.Broker, - config: params.Config, - log: params.Logger.Get(), + db: params.Database, + broker: params.Broker, + config: params.Config, + globals: params.Globals, + log: params.Logger.Get(), + wireConns: make(map[int64]WireConn), } } @@ -59,16 +84,219 @@ func NewTestService( database *db.Database, brk *broker.Broker, cfg *config.Config, + globs *globals.Globals, log *slog.Logger, ) *Service { return &Service{ - db: database, - broker: brk, - config: cfg, - log: log, + db: database, + broker: brk, + config: cfg, + globals: globs, + log: log, + wireConns: make(map[int64]WireConn), } } +// ServerVersion returns the version that VERSION and INFO +// report on both transports, such as "neoirc-1.2.3". +func (s *Service) ServerVersion() string { + version := s.globals.Version + if version == "" { + version = "dev" + } + + return s.globals.Appname + "-" + version +} + +// InfoLines returns the lines that INFO sends on both +// transports. +func (s *Service) InfoLines() []string { + return []string{ + "neoirc — IRC semantics over HTTP", + "Version: " + s.ServerVersion(), + "Written in Go", + "Started: " + s.globals.StartTime.Format(time.RFC1123), + } +} + +// AdminLines returns the texts of the RPL_ADMINLOC1, +// RPL_ADMINLOC2 and RPL_ADMINEMAIL replies, in that order, +// that ADMIN sends on both transports. +func (s *Service) AdminLines( + serverName string, +) (string, string, string) { + return "neoirc server", "IRC over HTTP", "admin@" + serverName +} + +// UserhostReply returns the RPL_USERHOST text for the first +// five of the given nicks: "nick=+user@host" entries joined +// by spaces, with * after the nick of an operator and - in +// place of + for a user who is away. Nicks with no session +// are left out. An empty hostname is reported as +// serverName. +func (s *Service) UserhostReply( + ctx context.Context, + nicks []string, + serverName string, +) (string, error) { + if len(nicks) > maxUserhostNicks { + nicks = nicks[:maxUserhostNicks] + } + + infos, err := s.db.GetUserhostInfo(ctx, nicks) + if err != nil { + return "", fmt.Errorf("userhost: %w", err) + } + + replies := make([]string, 0, len(infos)) + + for _, info := range infos { + operStar := "" + if info.IsOper { + operStar = "*" + } + + away := "+" + if info.AwayMessage != "" { + away = "-" + } + + username := info.Username + if username == "" { + username = info.Nick + } + + hostname := info.Hostname + if hostname == "" { + hostname = serverName + } + + replies = append(replies, + info.Nick+operStar+"="+away+username+"@"+hostname, + ) + } + + return strings.Join(replies, " "), nil +} + +// RegisterWireConn records the IRC connection of a session, +// so that KillUser can close it. +func (s *Service) RegisterWireConn( + sessionID int64, + conn WireConn, +) { + s.wireMu.Lock() + defer s.wireMu.Unlock() + + s.wireConns[sessionID] = conn +} + +// UnregisterWireConn removes what RegisterWireConn recorded, +// unless the session has since registered another +// connection. +func (s *Service) UnregisterWireConn( + sessionID int64, + conn WireConn, +) { + s.wireMu.Lock() + defer s.wireMu.Unlock() + + if s.wireConns[sessionID] == conn { + delete(s.wireConns, sessionID) + } +} + +// KillUser carries out an operator's KILL: the target's +// channel peers see it quit, its session is deleted, and +// its IRC connection, if it has one, is closed. An empty +// reason is given as "KILLed". +func (s *Service) KillUser( + ctx context.Context, + sessionID int64, + nick, targetNick, reason string, +) error { + err := s.requireOper(ctx, sessionID) + if err != nil { + return err + } + + targetSID, err := s.db.GetSessionByNick(ctx, targetNick) + if errors.Is(err, sql.ErrNoRows) { + return &IRCError{ + irc.ErrNoSuchNick, + []string{targetNick}, + msgNoSuchNick, + } + } + + if err != nil { + return fmt.Errorf("kill: %w", err) + } + + if targetSID == sessionID { + return &IRCError{ + irc.ErrCantKillServer, + nil, + "You cannot KILL yourself", + } + } + + if reason == "" { + reason = "KILLed" + } + + quitReason := "Killed (" + nick + " (" + reason + "))" + + s.BroadcastQuit(ctx, targetSID, targetNick, quitReason) + + s.wireMu.Lock() + conn := s.wireConns[targetSID] + s.wireMu.Unlock() + + if conn != nil { + conn.Disconnect(quitReason) + } + + return nil +} + +// SendWallops carries out an operator's WALLOPS: the +// message goes to every user with user mode +w. +func (s *Service) SendWallops( + ctx context.Context, + sessionID int64, + nick, message string, +) error { + err := s.requireOper(ctx, sessionID) + if err != nil { + return err + } + + recipients, err := s.db.GetWallopsSessionIDs(ctx) + if err != nil { + return fmt.Errorf("wallops: %w", err) + } + + if len(recipients) == 0 { + return nil + } + + body, err := json.Marshal([]string{message}) + if err != nil { + return fmt.Errorf("wallops: %w", err) + } + + _, _, err = s.FanOut( + ctx, irc.CmdWallops, nick, "*", + nil, body, nil, recipients, + ) + if err != nil { + return fmt.Errorf("wallops: %w", err) + } + + return nil +} + // IRCError represents an IRC protocol-level error with a // numeric code that both transports can map to responses. type IRCError struct { @@ -437,7 +665,7 @@ func (s *Service) KickUser( return &IRCError{ irc.ErrNoSuchNick, []string{targetNick}, - "No such nick/channel", + msgNoSuchNick, } } @@ -649,7 +877,7 @@ func (s *Service) ApplyMemberMode( return &IRCError{ irc.ErrNoSuchNick, []string{targetNick}, - "No such nick/channel", + msgNoSuchNick, } } @@ -792,6 +1020,139 @@ func (s *Service) QueryChannelMode( return modes + modeParams } +// QueryUserMode returns the session's user mode string, +// such as "+", "+w" or "+ow". +func (s *Service) QueryUserMode( + ctx context.Context, + sessionID int64, +) (string, error) { + modes := "+" + + isOper, err := s.db.IsSessionOper(ctx, sessionID) + if err != nil { + return "", fmt.Errorf( + "query oper flag: %w", err, + ) + } + + if isOper { + modes += "o" + } + + isWallops, err := s.db.IsSessionWallops( + ctx, sessionID, + ) + if err != nil { + return "", fmt.Errorf( + "query wallops flag: %w", err, + ) + } + + if isWallops { + modes += "w" + } + + return modes, nil +} + +// ApplyUserMode applies a user mode string to the session +// and returns the resulting mode string. The whole string +// is parsed before anything is written, and the flags are +// written in one transaction, so a change that is rejected +// or fails leaves the modes as they were. +func (s *Service) ApplyUserMode( + ctx context.Context, + sessionID int64, + modeStr string, +) (string, error) { + wallops, oper, err := parseUserModeString(modeStr) + if err != nil { + return "", err + } + + err = s.db.SetSessionUserModes( + ctx, sessionID, wallops, oper, + ) + if err != nil { + return "", fmt.Errorf("apply user modes: %w", err) + } + + return s.QueryUserMode(ctx, sessionID) +} + +// parseUserModeString parses a user mode string such as +// "+w", "-o" or "+w-o" into the new value of each flag it +// names, or nil for a flag it does not name; a later letter +// overrides an earlier one. The string must start with + or +// - and name at least one mode. w may be set or unset, o +// only unset (OPER sets it). Anything else is rejected with +// ERR_UMODEUNKNOWNFLAG. +func parseUserModeString( + modeStr string, +) (*bool, *bool, error) { + unknownFlag := &IRCError{ + irc.ErrUmodeUnknownFlag, nil, "Unknown MODE flag", + } + + if !strings.HasPrefix(modeStr, "+") && + !strings.HasPrefix(modeStr, "-") { + return nil, nil, unknownFlag + } + + var wallops, oper *bool + + adding := true + + for _, modeChar := range modeStr { + switch modeChar { + case '+': + adding = true + case '-': + adding = false + case 'w': + value := adding + wallops = &value + case 'o': + if adding { + return nil, nil, unknownFlag + } + + value := false + oper = &value + default: + return nil, nil, unknownFlag + } + } + + if wallops == nil && oper == nil { + return nil, nil, unknownFlag + } + + return wallops, oper, nil +} + +// requireOper returns ERR_NOPRIVILEGES unless the session +// is a server operator. +func (s *Service) requireOper( + ctx context.Context, + sessionID int64, +) error { + isOper, err := s.db.IsSessionOper(ctx, sessionID) + if err != nil { + return fmt.Errorf("check oper: %w", err) + } + + if !isOper { + return &IRCError{ + irc.ErrNoPrivileges, + nil, + "Permission Denied- You're not an IRC operator", + } + } + + return nil +} + // broadcastNickChange notifies channel peers of a nick // change. func (s *Service) broadcastNickChange( diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 864a8a8..2cc9ef2 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -10,7 +10,10 @@ import ( "errors" "fmt" "os" + "slices" + "strings" "testing" + "time" "go.uber.org/fx" "go.uber.org/fx/fxtest" @@ -55,9 +58,10 @@ func newTestEnv(t *testing.T) *testEnv { app := fxtest.New(t, fx.Provide( func() *globals.Globals { - return &globals.Globals{ //nolint:exhaustruct - Appname: "neoirc-test", - Version: "test", + return &globals.Globals{ + Appname: "neoirc-test", + Version: "test", + StartTime: time.Now(), } }, logger.New, @@ -363,3 +367,249 @@ func TestSendChannelMessage_Moderated(t *testing.T) { t.Errorf("operator should be able to send in moderated channel: %v", err) } } + +func TestQueryUserMode(t *testing.T) { + env := newTestEnv(t) + ctx := t.Context() + + sid := createSession(ctx, t, env.db, "alice") + + modes, err := env.svc.QueryUserMode(ctx, sid) + if err != nil { + t.Fatalf("query user mode: %v", err) + } + + if modes != "+" { + t.Errorf("expected +, got %s", modes) + } + + setUserModes(ctx, t, env.db, sid, userModes{wallops: true}) + + modes, err = env.svc.QueryUserMode(ctx, sid) + if err != nil { + t.Fatalf("query user mode: %v", err) + } + + if modes != "+w" { + t.Errorf("expected +w, got %s", modes) + } + + setUserModes( + ctx, t, env.db, sid, userModes{oper: true, wallops: true}, + ) + + modes, err = env.svc.QueryUserMode(ctx, sid) + if err != nil { + t.Fatalf("query user mode: %v", err) + } + + if modes != "+ow" { + t.Errorf("expected +ow, got %s", modes) + } + + // A flag that cannot be read is an error, not a flag + // that is unset. + cancelled, cancel := context.WithCancel(ctx) + cancel() + + _, err = env.svc.QueryUserMode(cancelled, sid) + if !errors.Is(err, context.Canceled) { + t.Errorf("expected the read's error, got %v", err) + } +} + +func TestUserhostReply(t *testing.T) { + env := newTestEnv(t) + ctx := t.Context() + + createSession(ctx, t, env.db, "alice") + bobID := createSession(ctx, t, env.db, "bob") + + // No username or hostname, and an operator. + operID, _, _, err := env.db.CreateSession( + ctx, "oper", "", "", "", + ) + if err != nil { + t.Fatal(err) + } + + setUserModes(ctx, t, env.db, operID, userModes{oper: true}) + + _, err = env.svc.SetAway(ctx, bobID, "gone fishing") + if err != nil { + t.Fatal(err) + } + + reply, err := env.svc.UserhostReply(ctx, []string{ + "alice", "bob", "nobody", "oper", + }, "srv") + if err != nil { + t.Fatal(err) + } + + want := "alice=+alice@localhost bob=-bob@localhost " + + "oper*=+oper@srv" + if reply != want { + t.Errorf("want %q, got %q", want, reply) + } + + reply, err = env.svc.UserhostReply( + ctx, slices.Repeat([]string{"bob"}, 6), "srv", + ) + if err != nil { + t.Fatal(err) + } + + if strings.Count(reply, "bob=") != 5 { + t.Errorf("want 5 entries for 6 nicks, got %q", reply) + } + + // A nick that cannot be looked up is an error, not a nick + // with no session. + cancelled, cancel := context.WithCancel(ctx) + cancel() + + _, err = env.svc.UserhostReply(cancelled, []string{"alice"}, "srv") + if !errors.Is(err, context.Canceled) { + t.Errorf("want the lookup's error, got %v", err) + } +} + +// userModes is the stored state of a session's user mode +// flags. +type userModes struct { + oper bool + wallops bool +} + +// applyUserModeTest is one mode string, the flags stored +// before it is applied, and the expected outcome. An empty +// wantModes means the string is rejected with +// ERR_UMODEUNKNOWNFLAG, and then after must equal before. +type applyUserModeTest struct { + modeStr string + before userModes + wantModes string + after userModes +} + +func applyUserModeTests() []applyUserModeTest { + none := userModes{} + oper := userModes{oper: true} + wallops := userModes{wallops: true} + both := userModes{oper: true, wallops: true} + + return []applyUserModeTest{ + {"+w", none, "+w", wallops}, + {"-w", wallops, "+", none}, + {"-o", oper, "+", none}, + {"-wo", both, "+", none}, + {"+w-o", oper, "+w", wallops}, + {"-w+w", none, "+w", wallops}, + {"+w-w", wallops, "+", none}, + {"-w+o", both, "", both}, + {"+o-w+w", both, "", both}, + {"+wo", none, "", none}, + {"+wz", none, "", none}, + {"+z", none, "", none}, + {"-x+y", none, "", none}, + {"+y-x", none, "", none}, + {"w", none, "", none}, + {"xw", wallops, "", wallops}, + {"", none, "", none}, + {"+", none, "", none}, + {"-", none, "", none}, + {"+-+", none, "", none}, + } +} + +func TestApplyUserMode(t *testing.T) { + env := newTestEnv(t) + + for i, test := range applyUserModeTests() { + t.Run(test.modeStr, func(t *testing.T) { + ctx := t.Context() + sid := createSession( + ctx, t, env.db, fmt.Sprintf("user%d", i), + ) + setUserModes(ctx, t, env.db, sid, test.before) + + modes, err := env.svc.ApplyUserMode( + ctx, sid, test.modeStr, + ) + checkApplyUserModeResult(t, test.wantModes, modes, err) + + got := getUserModes(ctx, t, env.db, sid) + if got != test.after { + t.Errorf( + "stored modes: want %+v, got %+v", + test.after, got, + ) + } + }) + } +} + +func checkApplyUserModeResult( + t *testing.T, + wantModes, modes string, + err error, +) { + t.Helper() + + if wantModes == "" { + var ircErr *service.IRCError + if !errors.As(err, &ircErr) || + ircErr.Code != irc.ErrUmodeUnknownFlag { + t.Fatalf("want ERR_UMODEUNKNOWNFLAG, got %v", err) + } + + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if modes != wantModes { + t.Errorf("modes: want %q, got %q", wantModes, modes) + } +} + +func setUserModes( + ctx context.Context, + t *testing.T, + database *db.Database, + sid int64, + modes userModes, +) { + t.Helper() + + err := database.SetSessionUserModes( + ctx, sid, &modes.wallops, &modes.oper, + ) + if err != nil { + t.Fatalf("set user modes: %v", err) + } +} + +func getUserModes( + ctx context.Context, + t *testing.T, + database *db.Database, + sid int64, +) userModes { + t.Helper() + + oper, err := database.IsSessionOper(ctx, sid) + if err != nil { + t.Fatalf("read oper: %v", err) + } + + wallops, err := database.IsSessionWallops(ctx, sid) + if err != nil { + t.Fatalf("read wallops: %v", err) + } + + return userModes{oper: oper, wallops: wallops} +} diff --git a/pkg/irc/commands.go b/pkg/irc/commands.go index 60ef85e..4e2e8af 100644 --- a/pkg/irc/commands.go +++ b/pkg/irc/commands.go @@ -2,26 +2,33 @@ package irc // IRC command names (RFC 1459 / RFC 2812). const ( - CmdAway = "AWAY" - CmdInvite = "INVITE" - CmdJoin = "JOIN" - CmdKick = "KICK" - CmdList = "LIST" - CmdLusers = "LUSERS" - CmdMode = "MODE" - CmdMotd = "MOTD" - CmdNames = "NAMES" - CmdNick = "NICK" - CmdNotice = "NOTICE" - CmdOper = "OPER" - CmdPass = "PASS" - CmdPart = "PART" - CmdPing = "PING" - CmdPong = "PONG" - CmdPrivmsg = "PRIVMSG" - CmdQuit = "QUIT" - CmdTopic = "TOPIC" - CmdUser = "USER" - CmdWho = "WHO" - CmdWhois = "WHOIS" + CmdAdmin = "ADMIN" + CmdAway = "AWAY" + CmdInfo = "INFO" + CmdInvite = "INVITE" + CmdJoin = "JOIN" + CmdKick = "KICK" + CmdKill = "KILL" + CmdList = "LIST" + CmdLusers = "LUSERS" + CmdMode = "MODE" + CmdMotd = "MOTD" + CmdNames = "NAMES" + CmdNick = "NICK" + CmdNotice = "NOTICE" + CmdOper = "OPER" + CmdPass = "PASS" + CmdPart = "PART" + CmdPing = "PING" + CmdPong = "PONG" + CmdPrivmsg = "PRIVMSG" + CmdQuit = "QUIT" + CmdTime = "TIME" + CmdTopic = "TOPIC" + CmdUser = "USER" + CmdUserhost = "USERHOST" + CmdVersion = "VERSION" + CmdWallops = "WALLOPS" + CmdWho = "WHO" + CmdWhois = "WHOIS" ) -- 2.54.0 From ccb2bf796040d9897e81a6c9f93dbfd5c89da3fd Mon Sep 17 00:00:00 2001 From: clawbot Date: Thu, 8 Oct 2026 10:47:55 +0200 Subject: [PATCH 09/40] Keep active IRC listener users from expiring (closes #121) Every command a registered IRC listener client sends, PING and PONG included, now sets last_seen on its session and its client, so the idle cleanup no longer removes an IRC user who is still active; one who sends nothing for SESSION_IDLE_TIMEOUT is still removed. The two UPDATEs that GetSessionByToken ran for every authenticated HTTP API request move into UpdateLastSeen, which both transports now call. The IRC listener refreshes on every command, with no interval between writes: that is what every HTTP API request already costs. README.md describes last_seen to match. Model: opus-5-5 Co-authored-by: clawbot --- README.md | 4 ++-- internal/db/queries.go | 27 ++++++++++++++++++++++--- internal/ircserver/conn.go | 11 +++++++++++ internal/ircserver/server_test.go | 33 +++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6080c68..f6c166a 100644 --- a/README.md +++ b/README.md @@ -2243,7 +2243,7 @@ The database schema is managed via embedded SQL migration files in | `signing_key` | TEXT | Public signing key (empty string if unset) | | `away_message` | TEXT | Away message (empty string if not away) | | `created_at` | DATETIME | Session creation time | -| `last_seen` | DATETIME | Last API request time | +| `last_seen` | DATETIME | Last API request or IRC listener command time | Index on `(uuid)`. @@ -2258,7 +2258,7 @@ Index on `(uuid)`. | `ip` | TEXT | Real IP address of this client connection | | `hostname` | TEXT | Reverse DNS hostname of this client connection | | `created_at` | DATETIME | Client creation time | -| `last_seen` | DATETIME | Last API request time | +| `last_seen` | DATETIME | Last API request or IRC listener command time | Indexes on `(token)` and `(session_id)`. diff --git a/internal/db/queries.go b/internal/db/queries.go index 7b437df..29239ea 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -204,21 +204,42 @@ func (database *Database) GetSessionByToken( ) } + _ = database.UpdateLastSeen(ctx, sessionID, clientID) + + return sessionID, clientID, nick, nil +} + +// UpdateLastSeen sets last_seen to now on a session and on +// one of its clients, so that the idle cleanup keeps them. +func (database *Database) UpdateLastSeen( + ctx context.Context, + sessionID, clientID int64, +) error { now := time.Now() - _, _ = database.conn.ExecContext( + _, err := database.conn.ExecContext( ctx, "UPDATE sessions SET last_seen = ? WHERE id = ?", now, sessionID, ) + if err != nil { + return fmt.Errorf( + "update session last_seen: %w", err, + ) + } - _, _ = database.conn.ExecContext( + _, err = database.conn.ExecContext( ctx, "UPDATE clients SET last_seen = ? WHERE id = ?", now, clientID, ) + if err != nil { + return fmt.Errorf( + "update client last_seen: %w", err, + ) + } - return sessionID, clientID, nick, nil + return nil } // GetSessionByNick returns session id for a given nick. diff --git a/internal/ircserver/conn.go b/internal/ircserver/conn.go index 74e4dd1..f8024f0 100644 --- a/internal/ircserver/conn.go +++ b/internal/ircserver/conn.go @@ -368,6 +368,17 @@ func (c *Conn) handleMessage( 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] if !ok { c.sendNumeric( diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index 3717474..99e860b 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -369,6 +369,39 @@ func TestPingPong(t *testing.T) { assertContains(t, lines, "PONG", "PONG response") } +// 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() + + const idleTimeout = 500 * time.Millisecond + + env := newTestEnv(t) + + active := env.dial(t) + active.register("active") + + idle := env.dial(t) + idle.register("idle") + + time.Sleep(idleTimeout) + 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(), time.Now().Add(-idleTimeout), + ) + 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) { t.Parallel() -- 2.54.0 From e80c9552ebc0d75cb8af09e814d05ec64b0e4d75 Mon Sep 17 00:00:00 2001 From: clawbot Date: Thu, 8 Oct 2026 11:30:48 +0200 Subject: [PATCH 10/40] Bring README.md in line with the modes and the schema (closes #119) The MODE section described a query-only command. It now covers channel and user mode queries and changes on the HTTP API and the IRC listener, the letters each accepts, what is broadcast, and the error numerics. The schema section now lists every table and column in 001_initial.sql. Every other statement this change touches was checked against the code: polled numerics carry their name in command and their number in code; command errors are numerics with HTTP 200; the broker is keyed by session; WAL is never on; a hostname falls back to the IP and is public; and the IRC listener handles modes, NOTICE, INVITE, +s and +H more narrowly, with gaps in its bridge to the HTTP API. Model: opus-5-5 Co-authored-by: clawbot --- README.md | 1144 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 713 insertions(+), 431 deletions(-) diff --git a/README.md b/README.md index f6c166a..3fe6cf4 100644 --- a/README.md +++ b/README.md @@ -98,10 +98,10 @@ IRC messages are single lines of text. That's a protocol constraint from 1988, not a deliberate design choice. It forces multiline content through ugly workarounds (multiple PRIVMSG commands, paste flood). -Message bodies here are JSON arrays (one string per line) or objects (for -structured data like key material). This also enables deterministic -canonicalization via RFC 8785 JCS — you can't reliably sign something if the -wire representation is ambiguous. +Message bodies here are JSON arrays (one string per line), with objects planned +for structured data like key material (see `PUBKEY`). This also enables +deterministic canonicalization via RFC 8785 JCS — you can't reliably sign +something if the wire representation is ambiguous. ### 4. Key/value metadata on messages @@ -129,8 +129,7 @@ rather than space-delimited text. The command vocabulary is IRC's, not an invention. The message envelope is deliberately identical for C2S and S2C. A `PRIVMSG` is a -`PRIVMSG` regardless of direction. A `JOIN` from a client is the same shape as -the `JOIN` relayed to channel members. This keeps the protocol simple and makes +`PRIVMSG` regardless of direction. This keeps the protocol simple and makes signing consistent — you sign the same structure you send. ### Why not XMPP or Matrix? @@ -160,9 +159,11 @@ for multi-client access. #### Session Creation - **Session creation**: client sends `POST /api/v1/session` with a desired nick - → server sets an **HttpOnly auth cookie** (`neoirc_auth`) containing a - cryptographically random value (64 hex characters) and returns the user ID and - nick in the JSON response body. No auth credential appears in the JSON body. + and, unless the server has hashcash turned off, a proof-of-work stamp (see + [Hashcash Proof-of-Work](#hashcash-proof-of-work)) → server sets an **HttpOnly + auth cookie** (`neoirc_auth`) containing a cryptographically random value (64 + hex characters) and returns the user ID and nick in the JSON response body. No + auth credential appears in the JSON body. - The auth cookie is HttpOnly, SameSite=Strict, and Secure: clients send it only over HTTPS, which the TLS-terminating reverse proxy provides, or to a server on `localhost` (see [Transport Security](#transport-security)). Browsers @@ -182,10 +183,10 @@ For users who want to access the same session from multiple devices: must be at least 8 characters. - **Login from another client**: `POST /api/v1/login` with nick and password → server verifies the password, creates a new client for the existing session, - and sets an auth cookie. Channel memberships and message queues are shared. - Login only works while the session still exists — if all clients have logged - out or the user has sent QUIT, the session is deleted and the password is - lost. + and sets an auth cookie. Channel memberships are shared; the new client gets + its own message queue. Login only works while the session still exists — if + all clients have logged out or the user has sent QUIT, the session is deleted + and the password is lost. #### Common Properties @@ -204,8 +205,8 @@ casual users: if you don't need multi-client, just create a session and go. Cookie-based auth simplifies credential management — browsers handle cookies automatically, and CLI clients just need a cookie jar (e.g., curl's `-c`/`-b` flags). Note: both anonymous and password-protected sessions are deleted when -the last client disconnects (QUIT or logout). Identity verification at the -message layer via cryptographic signatures (see +any client sends QUIT, or when the last client logs out. Identity verification +at the message layer via cryptographic signatures (see [Security Model](#security-model)) remains independent of password status. ### Hostmask (nick!user@host) @@ -215,29 +216,32 @@ Each session has an IRC-style hostmask composed of three parts: - **nick** — the user's current nick (changes with `NICK` command) - **username** — an ident-like identifier set at session creation (optional `username` field in the session request; defaults to the nick) -- **hostname** — automatically resolved via reverse DNS of the connecting - client's IP address at session creation time +- **hostname** — the reverse DNS name of the creating client's IP address, + looked up at session creation time, or the IP address itself when the lookup + gives no name - **ip** — the real IP address of the session creator, extracted from `X-Forwarded-For`, `X-Real-IP`, or `RemoteAddr` Each **client connection** (created at session creation or login) also stores -its own **ip** and **hostname**, allowing the server to track the network origin -of each individual client independently from the session. Client-level IP and -hostname are **not displayed to regular users**. They are only visible to -**server operators** (o-line) via `RPL_WHOISACTUALLY` (338) when the oper -performs a WHOIS on a user. +its own **ip** and **hostname**, found the same way. The session's hostname is +the hostname of the client that created it, and every user can see it: in WHOIS +(311), WHO (352), USERHOST (302), NAMES (353) over the HTTP API, and +`GET /api/v1/channels/{name}/members`. Only `RPL_WHOISACTUALLY` (338) is for +**server operators** (o-line) alone: it gives the IP address and hostname of the +target's newest client. The hostmask appears in: - **WHOIS** (`311 RPL_WHOISUSER`) — `params` contains `[nick, username, hostname, "*"]` - **WHOIS (oper-only)** (`338 RPL_WHOISACTUALLY`) — when the querier is a server - operator, includes the target's current client IP and hostname + operator, includes the IP address and hostname of the target's newest client + (HTTP API only; the IRC listener's WHOIS does not send it) - **WHO** (`352 RPL_WHOREPLY`) — `params` contains `[channel, username, hostname, server, nick, flags]` -The hostmask format (`nick!user@host`) is stored for future use in ban matching -(`+b` mode) and other access control features. +The hostmask (`nick!user@host`) is built from these fields when needed, and is +what channel bans (`+b` mode) match against. ### Nick Semantics @@ -245,8 +249,9 @@ The hostmask format (`nick!user@host`) is stored for future use in ban matching hold the same nick simultaneously. - Nicks are **case-sensitive** (unlike traditional IRC). `Alice` and `alice` are different nicks. -- Nick length: 1–32 characters. No further character restrictions in the current - implementation. +- Nick length: 1–32 characters. Over the HTTP API a nick starts with a letter or + `_`, followed by letters, digits and `` _-[]\^{}|` ``. The IRC listener cuts a + longer nick to 32 bytes and checks nothing else. - Nicks are **released when a session is destroyed** (via `QUIT` command or session expiry). There is no nick registration or reservation system. - Nick changes are broadcast to all users sharing a channel with the changer, as @@ -261,8 +266,10 @@ rules). Case-sensitive comparison is unambiguous. A single user session can have multiple clients (phone, laptop, terminal). - Each client gets a **separate server-to-client (S2C) message queue**. -- The server fans out all S2C messages to every active client queue for that - user session. +- The server fans out messages and events from other users, and the user's own + DMs and the channel messages it sends over the HTTP API, to every client queue + for that user session. Replies to a command (numerics) go only to the client + that sent it. - `GET /api/v1/messages` delivers from the calling client's specific queue, identified by the auth cookie. - Client queues have **independent expiry/pruning** — one client going offline @@ -331,10 +338,10 @@ The server implements HTTP long-polling for real-time message delivery: - The client disconnects (connection closed, no response needed) **Implementation detail:** The server maintains an in-memory broker with -per-client notification channels. When a message is enqueued for a client, the -broker closes all waiting channels for that client, waking up any blocked -long-poll handlers. This is O(1) notification — no polling loops, no database -scanning. +per-session notification channels. When a message is enqueued for a session, the +broker signals every waiting channel for that session, waking up any blocked +long-poll handlers of its clients. This is O(1) notification — no polling loops, +no database scanning. **Timeout limits:** The server caps the `timeout` parameter at 30 seconds. Clients should use 15 seconds as the default. The HTTP write timeout is set to @@ -354,7 +361,7 @@ balancer, and CDN handles it correctly. exactly like IRC. - **Ephemeral** — channels disappear when the last member leaves. There is no persistent channel registration. -- **No channel size limits** in the current implementation. +- **No channel size limits** unless an operator sets one with mode `+l`. - **Channel names** must start with `#`. If a client sends a `JOIN` without the `#` prefix, the server adds it. - **No channel-level encryption** — encryption is per-message via the `meta` @@ -410,15 +417,18 @@ Opaque auth cookies are simpler: ### Transport: HTTP Only All client↔server and server↔server communication uses HTTP/1.1+ with JSON -request/response bodies. No WebSockets, no raw TCP, no gRPC — just plain HTTP. +request/response bodies. No WebSockets, no gRPC — just plain HTTP. The one +exception is the [IRC Protocol Listener](#irc-protocol-listener), a raw TCP +listener for standard IRC clients, which is on by default. - **Client reading**: Long-poll `GET /api/v1/messages` — server holds the - connection for up to 15s until messages arrive or timeout. One endpoint for - everything — channel messages, DMs, system events, numeric replies. + connection for up to the `timeout` the client asks for (at most 30s) until + messages arrive. One endpoint for everything — channel messages, DMs, system + events, numeric replies. - **Client writing**: `POST /api/v1/messages` with a `command` field. One endpoint for everything — PRIVMSG, JOIN, PART, NICK, TOPIC, etc. -- **Server federation**: Servers exchange messages via HTTP to enable - multi-server networks (like IRC server linking). +- **Server federation** (planned, not yet implemented): Servers exchange + messages via HTTP to enable multi-server networks (like IRC server linking). The entire read/write loop for a client is two endpoints. Everything else (state, history, channels, members, server info) is ancillary. @@ -430,18 +440,19 @@ The entire read/write loop for a client is two endpoints. Everything else ``` ┌─ Client ──────────────────────────────────────────────────┐ │ │ -│ 1. POST /api/v1/session {"nick":"alice"} │ +│ 1. POST /api/v1/session │ +│ {"nick":"alice","pow_token":""} │ │ → Set-Cookie: neoirc_auth=; HttpOnly; ... │ │ → {"id":1, "nick":"alice"} │ │ │ │ 2. POST /api/v1/messages {"command":"JOIN","to":"#gen"} │ -│ → {"status":"joined","channel":"#general"} │ +│ → {"status":"joined","channel":"#gen"} │ │ (Cookie must be sent on all subsequent requests) │ │ │ │ 3. POST /api/v1/messages {"command":"PRIVMSG", │ -│ "to":"#general","body":["hello"]} │ +│ "to":"#gen","body":["hello"]} │ │ → {"id":"uuid-...","status":"sent"} │ -│ (Server fans out to all #general members' queues) │ +│ (Server fans out to all #gen members' queues) │ │ │ │ 4. GET /api/v1/messages?after=0&timeout=15 │ │ ← (held open up to 15s until messages arrive) │ @@ -463,7 +474,8 @@ The entire read/write loop for a client is two endpoints. Everything else ``` ┌─ Client A ────────────────────────────────────────────────┐ │ │ -│ 1. POST /api/v1/session {"nick":"alice"} │ +│ 1. POST /api/v1/session │ +│ {"nick":"alice","pow_token":""} │ │ → Set-Cookie: neoirc_auth=; HttpOnly; ... │ │ → {"id":1, "nick":"alice"} │ │ │ @@ -483,7 +495,7 @@ The entire read/write loop for a client is two endpoints. Everything else │ → Set-Cookie: neoirc_auth=; HttpOnly; ... │ │ → {"id":1, "nick":"alice"} │ │ (New client added to existing session — channels │ -│ and message queues are preserved.) │ +│ are kept; the new client gets its own queue.) │ │ │ └────────────────────────────────────────────────────────────┘ ``` @@ -518,11 +530,12 @@ returns the results. The `queue_id` (auto-incrementing primary key of ### In-Memory Broker The server maintains an in-memory notification broker to avoid database polling. -The broker is a map of `client_id → []chan struct{}`. When a message is enqueued -for a client: +The broker is a map of `session_id → []chan struct{}`. When a message is +enqueued for a session: -1. The handler calls `broker.Notify(clientID)` -2. The broker closes all waiting channels for that client +1. The handler calls `broker.Notify(sessionID)` +2. The broker sends a signal on each waiting channel for that session and + forgets them 3. Any goroutines blocked in `select` on those channels wake up 4. The woken handler queries the database for new queue entries 5. Messages are returned to the client @@ -544,6 +557,7 @@ the same JSON envelope: { "id": "string (uuid)", "command": "string", + "code": integer, "from": "string", "to": "string", "params": ["string", ...], @@ -555,16 +569,17 @@ the same JSON envelope: #### Field Reference -| Field | Type | C2S | S2C | Description | -| --------- | ----------------- | --------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `id` | string (UUID v4) | Ignored | Always | Server-assigned unique message identifier. | -| `command` | string | Required | Always | IRC command name (`PRIVMSG`, `JOIN`, etc.) or 3-digit numeric reply code (`001`, `433`, etc.). Case-insensitive on input; server normalizes to uppercase. | -| `from` | string | Ignored | Usually | Sender's nick (for user messages) or server name (for server messages). Server always overwrites this field — clients cannot spoof the sender. | -| `to` | string | Usually | Usually | Destination: `#channel` for channel targets, bare nick for DMs/user targets. | -| `params` | array of strings | Sometimes | Sometimes | Additional IRC-style positional parameters. Used by commands like `MODE`, `KICK`, and numeric replies like `353` (NAMES). | -| `body` | array or object | Usually | Usually | Structured message body. For text messages: array of strings (one per line). For structured data (e.g., `PUBKEY`): JSON object. **Never a raw string.** | -| `ts` | string (ISO 8601) | Ignored | Always | Server-assigned timestamp in RFC 3339 / ISO 8601 format with nanosecond precision. Example: `"2026-02-10T20:00:00.000000000Z"`. Always UTC. | -| `meta` | object | Optional | If present | Extensible metadata. Used for cryptographic signatures (`meta.sig`, `meta.alg`), hashcash proof-of-work (`meta.hashcash`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim except for `hashcash` which is validated on channels with `+H` mode. | +| Field | Type | C2S | S2C | Description | +| --------- | ----------------- | -------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | string (UUID v4) | Ignored | Always | Server-assigned unique message identifier. | +| `command` | string | Required | Always | IRC command name (`PRIVMSG`, `JOIN`, etc.), or for a numeric reply its name (`RPL_WELCOME`, `ERR_NICKNAMEINUSE`, etc.). Case-insensitive on input; server normalizes to uppercase. | +| `code` | integer | Ignored | Numerics | The number of a numeric reply (`1` for `RPL_WELCOME`, `433` for `ERR_NICKNAMEINUSE`); absent on other messages. | +| `from` | string | Ignored | Usually | Sender's nick (for user messages) or server name (for server messages). Server always overwrites this field — clients cannot spoof the sender. | +| `to` | string | Usually | Usually | Destination: `#channel` for channel targets, bare nick for DMs/user targets. | +| `params` | array of strings | Ignored | Sometimes | Additional IRC-style positional parameters, used by `KICK` and numeric replies like `353` (NAMES). Commands take their arguments from `to` and `body`. | +| `body` | array or object | Usually | Usually | Structured message body. For text messages: array of strings (one per line). Objects, for structured data such as `PUBKEY`, are planned; no command accepts one yet. **Never a raw string.** | +| `ts` | string (ISO 8601) | Ignored | Always | Server-assigned timestamp in RFC 3339 / ISO 8601 format with up to nanosecond precision, trailing zeros dropped. Example: `"2026-02-10T20:00:00.123456789Z"`. Always UTC. | +| `meta` | object | Optional | If present | Extensible metadata. Used for cryptographic signatures (`meta.sig`, `meta.alg`), hashcash proof-of-work (`meta.hashcash`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim except for `hashcash` which is validated on channels with `+H` mode. | **Important invariants:** @@ -575,7 +590,8 @@ the same JSON envelope: - `id` and `ts` are **always set by the server**. Client-supplied values are ignored. - `meta` is **relayed verbatim**. The server stores it as-is and includes it in - S2C messages. It is never modified, validated, or interpreted by the server. + S2C messages. It is never modified, and the server reads nothing in it except + `meta.hashcash` on a `PRIVMSG` to a `+H` channel. ### Commands (C2S and S2C) @@ -607,7 +623,7 @@ Send a message to a channel or user. This is the primary messaging command. "from": "alice", "to": "#general", "body": ["hello world"], - "ts": "2026-02-10T20:00:00.000000000Z", + "ts": "2026-02-10T20:00:00.123456789Z", "meta": {} } ``` @@ -619,16 +635,25 @@ Send a message to a channel or user. This is the primary messaging command. message echoed back via the queue). - If `to` is a bare nick, the message is a DM. The server fans out to the recipient and the sender (so all of the sender's clients see the DM). -- `body` must be a non-empty array of strings. -- If the channel doesn't exist, the server returns HTTP 404. -- If the DM target nick doesn't exist, the server returns HTTP 404. +- `body` must be a non-empty array of strings; otherwise the server sends + ERR_NOTEXTTOSEND (412). A missing `to` gets ERR_NORECIPIENT (411). +- Only channel members can send to a channel. A non-member, a banned user, and + on a `+m` channel a user with neither `+o` nor `+v`, get ERR_CANNOTSENDTOCHAN + (404). +- If the channel doesn't exist, the server sends ERR_NOSUCHCHANNEL (403). +- If the DM target nick doesn't exist, the server sends ERR_NOSUCHNICK (401). +- The sender of a DM to a user who is away gets RPL_AWAY (301) with their away + message. -**Response:** `201 Created` +**Response:** `200 OK` ```json { "id": "uuid-string", "status": "sent" } ``` +When the server sends an error numeric instead, the response is +`{"status": "error"}`. + **IRC reference:** RFC 1459 §4.4.1 #### NOTICE — Send Notice @@ -646,8 +671,9 @@ This prevents infinite loops between automated systems. } ``` -**Behavior:** Same as PRIVMSG in all respects, except clients receiving a NOTICE -must not send an automatic reply. +**Behavior:** Same as PRIVMSG, except that clients receiving a NOTICE must not +send an automatic reply, and over the HTTP API the server sends no RPL_AWAY for +it and does not check hashcash on `+H` channels. **IRC reference:** RFC 1459 §4.4.2 @@ -660,9 +686,13 @@ Join a channel. If the channel doesn't exist, it is created. ```json {"command": "JOIN", "to": "#general"} {"command": "JOIN", "to": "general"} +{"command": "JOIN", "to": "#general", "body": ["secretpass"]} ``` -If the `#` prefix is omitted, the server adds it. +If the `#` prefix is omitted, the server adds it. The name must then be `#` +followed by 1–63 letters, digits, `_` or `-`; anything else gets +ERR_NOSUCHCHANNEL (403). `body[0]`, if present, is the channel key for a `+k` +channel. **S2C (broadcast to all channel members, including the joiner):** @@ -672,22 +702,25 @@ If the `#` prefix is omitted, the server adds it. "command": "JOIN", "from": "alice", "to": "#general", - "body": [], - "ts": "2026-02-10T20:00:00.000000000Z", + "body": ["#general"], + "ts": "2026-02-10T20:00:00.123456789Z", "meta": {} } ``` **Behavior:** -- If the channel doesn't exist, it is created with no topic and no modes. -- If the user is already in the channel, the JOIN is a no-op (no error, no - duplicate broadcast). +- If the channel doesn't exist, it is created with no topic and modes `+nt`. +- The joining client then gets the topic (RPL_TOPIC 332 with RPL_TOPICWHOTIME + 333, or RPL_NOTOPIC 331) and the member list (RPL_NAMREPLY 353, RPL_ENDOFNAMES + 366). +- A JOIN by a user already in the channel goes through the same checks and is + broadcast and answered again; the membership does not change. - The JOIN event is broadcast to **all** channel members, including the user who joined. This lets the client confirm the join succeeded and lets other members update their member lists. -- The first user to join a channel becomes its implicit operator (not yet - enforced in current implementation). +- The first user to join a channel becomes its operator (`+o`). Bans, `+i`, `+k` + and `+l` can refuse anyone else (see [Channel Modes](#channel-modes)). **Response:** `200 OK` @@ -708,7 +741,7 @@ Leave a channel. {"command": "PART", "to": "#general", "body": ["goodbye"]} ``` -**S2C (broadcast to all channel members, including the leaver):** +**S2C (broadcast to the other channel members):** ```json { @@ -724,8 +757,9 @@ Leave a channel. **Behavior:** -- The PART event is broadcast **before** the member is removed, so the departing - user receives their own PART event. +- The PART event goes to the other channel members, not to the departing user; + the HTTP response confirms the part. (The IRC listener echoes the PART to its + own connection.) - If the channel is empty after the user leaves, the channel is **deleted** (ephemeral channels). - If the user is not in the channel, the server returns an error. @@ -756,7 +790,6 @@ Change the user's nickname. "id": "...", "command": "NICK", "from": "oldnick", - "to": "", "body": ["newnick"], "ts": "...", "meta": {} @@ -765,13 +798,15 @@ Change the user's nickname. **Behavior:** -- `body[0]` is the new nick. Must be 1–32 characters. +- `body[0]` is the new nick. It must be a valid nick (see + [Nick Semantics](#nick-semantics)); otherwise the server sends + ERR_ERRONEUSNICKNAME (432). - The `from` field in the broadcast contains the **old** nick. - The `body[0]` in the broadcast contains the **new** nick. - The NICK event is broadcast to the user themselves and to all users who share at least one channel with the changer. Each recipient receives the event exactly once, even if they share multiple channels. -- If the new nick is already taken, the server returns HTTP 409 Conflict. +- If the new nick is already taken, the server sends ERR_NICKNAMEINUSE (433). **Response:** `200 OK` @@ -779,11 +814,8 @@ Change the user's nickname. { "status": "ok", "nick": "newnick" } ``` -**Error (nick taken):** `409 Conflict` - -```json -{ "error": "nick already in use" } -``` +When the server sends an error numeric instead, the response is +`{"status": "error"}`. **IRC reference:** RFC 1459 §4.1.2 @@ -844,7 +876,10 @@ Set or change a channel's topic. **Behavior:** - Updates the channel's topic in the database. -- The TOPIC event is broadcast to all channel members. +- The TOPIC event is broadcast to all channel members, and the setter's client + also gets RPL_TOPIC (332) and RPL_TOPICWHOTIME (333). +- `body` is required: there is no topic query over the HTTP API, and a TOPIC + without one gets ERR_NEEDMOREPARAMS (461). - If the channel doesn't exist, the server returns an error. - If the channel has mode `+t` (topic lock, default: ON for new channels), only operators (`+o`) can change the topic. Non-operators receive @@ -876,7 +911,6 @@ Destroy the session and disconnect from the server. "id": "...", "command": "QUIT", "from": "alice", - "to": "", "body": ["leaving"], "ts": "...", "meta": {} @@ -889,8 +923,8 @@ Destroy the session and disconnect from the server. user. The quitting user does **not** receive their own QUIT. - The user is removed from all channels. - Empty channels are deleted (ephemeral). -- The user's session is destroyed — the auth cookie is invalidated, the nick is - released. +- The user's session is destroyed — the auth cookies of all its clients are + invalidated, the nick is released. - Subsequent requests with the old auth cookie return HTTP 401. **Response:** `200 OK` @@ -923,36 +957,95 @@ pollute the message queue. **IRC reference:** RFC 1459 §4.6.2, §4.6.3 -#### MODE — Query Modes +#### MODE — Query and Change Modes -Query channel or user modes. Returns the current mode string and, for channels, -the creation timestamp. +Query or change a channel's modes, or query or change your own user modes. A +`to` that starts with `#` is a channel; any other `to` is a nick. **C2S:** ```json {"command": "MODE", "to": "#general"} +{"command": "MODE", "to": "#general", "body": ["+m"]} +{"command": "MODE", "to": "#general", "body": ["+o", "bob"]} {"command": "MODE", "to": "alice"} +{"command": "MODE", "to": "alice", "body": ["+w"]} ``` -**S2C (via message queue):** - -For channels, the server sends RPL_CHANNELMODEIS (324) and RPL_CREATIONTIME -(329): +**Channel query:** with no `body`, the server sends RPL_CHANNELMODEIS (324) and +RPL_CREATIONTIME (329). Anyone may query, member or not. The mode string lists +the set flags out of `nimst`, then `k`, `l` and `H` with their values (key, +member limit, bits) after it, e.g. `+ntkl secretpass 50`. A new channel has +`+nt`: ```json -{"command": "324", "to": "alice", "params": ["#general", "+n"]} -{"command": "329", "to": "alice", "params": ["#general", "1709251200"]} +{"command": "RPL_CHANNELMODEIS", "code": 324, "to": "alice", "params": ["#general", "+nt"]} +{"command": "RPL_CREATIONTIME", "code": 329, "to": "alice", "params": ["#general", "1709251200"]} ``` -For users, the server sends RPL_UMODEIS (221): +**Channel changes:** only channel operators can change channel modes. Over the +HTTP API, `body[0]` is one change, a sign and one letter, and `body[1]` is its +value where it takes one; a string of several letters such as `+mt` is not +accepted. The letters: + +| Change | Value | Effect | +| ----------- | --------------------------- | ----------------------------------------------------------------- | +| `+o` / `-o` | nick | Give or take channel operator | +| `+v` / `-v` | nick | Give or take voice | +| `+i` / `-i` | | Invite-only on or off | +| `+m` / `-m` | | Moderated on or off | +| `+s` / `-s` | | Secret on or off | +| `+t` / `-t` | | Topic lock on or off | +| `+k` / `-k` | key, for `+k` | Set or clear the channel key | +| `+l` / `-l` | positive integer, for `+l` | Set or clear the member limit | +| `+b` / `-b` | `nick!user@host` mask | Add or remove a ban; `+b` with no mask lists the bans, for anyone | +| `+H` / `-H` | bits from 1 to 40, for `+H` | Require hashcash on `PRIVMSG`, or stop requiring it | + +A change made over the HTTP API is sent to every channel member, the operator +included, as a `MODE` message from the operator to the channel. Its `body` is +the change as one string for `o`, `v`, `i`, `m`, `s` and `t` (`["+o bob"]`, +`["+m"]`), and as the change and its value for `k`, `l` and `b` +(`["+k", "secretpass"]`, `["-k", "*"]`, `["+l", "50"]`, `["-l"]`, +`["+b", "*!*@*.example.com"]`). A `+H` or `-H` change is not sent to the +members; the operator gets RPL_CHANNELMODEIS (324) with the new mode string. +Listing bans sends RPL_BANLIST (367) for each ban, with the mask, who set it and +when, then RPL_ENDOFBANLIST (368). + +On the IRC listener, `MODE #channel [values...]` takes several letters +in one string, e.g. `MODE #general +mt-i` or `MODE #general +ov alice bob`. It +accepts `i`, `m`, `n`, `s`, `t`, `o` and `v` (with a nick) and `H` (with bits +from 1 to 40 when setting), but not `b`, `k` or `l`. A letter whose value is +missing is skipped. The changes made are echoed to the operator's connection and +also sent to every channel member, the operator included, as one `MODE` line, +e.g. `+o-v alice bob`. + +**Channel errors:** + +| Numeric | Name | When | +| ------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `403` | ERR_NOSUCHCHANNEL | The channel does not exist | +| `482` | ERR_CHANOPRIVSNEEDED | A change by someone who is not a channel operator; over the HTTP API, a letter that is not accepted gets 472 instead | +| `461` | ERR_NEEDMOREPARAMS | No `to`; over the HTTP API, also `+o`, `-o`, `+v`, `-v`, `+k`, `+l`, `-b` or `+H` without its value | +| `472` | ERR_UNKNOWNMODE | A letter that is not accepted, `+H` bits that are not a whole number from 1 to 40, or over the HTTP API a `+l` value that is not a positive whole number | +| `401` | ERR_NOSUCHNICK | The `+o`/`+v` nick does not exist | +| `441` | ERR_USERNOTINCHANNEL | The `+o`/`+v` nick is not in the channel | + +**User modes:** you can query and change only your own; a nick other than your +own, compared without regard to letter case, gets ERR_USERSDONTMATCH (502). With +no mode string the server sends RPL_UMODEIS (221) with your modes: `+`, `+o`, +`+w` or `+ow`. A change is a mode string, `body[0]` over the HTTP API or the +parameter after the nick on the IRC listener, that starts with `+` or `-`: `w` +(receive `WALLOPS`) can be set or unset, and `o` (server operator) only unset, +since only `OPER` sets it. Any other letter, or a string that names no mode, +gets ERR_UMODEUNKNOWNFLAG (501) and changes nothing. A change that is accepted +is answered with 221 and the new modes; it is not sent to anyone else. ```json -{ "command": "221", "to": "alice", "body": ["+"] } +{ "command": "RPL_UMODEIS", "code": 221, "to": "alice", "body": ["+w"] } ``` -**Note:** Mode changes (setting/unsetting modes) are not yet implemented. -Currently only query is supported. +**Response:** `200 OK` with `{"status": "ok"}`, or `{"status": "error"}` when +the reply is an error numeric. **IRC reference:** RFC 1459 §4.2.3 @@ -989,8 +1082,9 @@ Query information about a user. Returns RPL_WHOISUSER (311), RPL_WHOISSERVER RPL_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318). If the querying user is a **server operator** (authenticated via `OPER`), the -response additionally includes RPL_WHOISACTUALLY (338) with the target's current -client IP address and hostname. +response over the HTTP API additionally includes RPL_WHOISACTUALLY (338) with +the IP address and hostname of the target's newest client. The IRC listener's +WHOIS does not send it. **C2S:** @@ -1031,8 +1125,9 @@ LUSERS replies are also sent automatically during connection registration. #### OPER — Gain Server Operator Status Authenticate as a server operator (o-line). On success, the session gains oper -privileges, which currently means additional information is visible in WHOIS -responses (e.g., target user's current client IP and hostname). +privileges: it can use `KILL` and `WALLOPS`, and over the HTTP API its WHOIS +responses include more (the IP address and hostname of the target user's newest +client). **C2S:** @@ -1043,7 +1138,12 @@ responses (e.g., target user's current client IP and hostname). **S2C (via message queue on success):** ```json -{ "command": "381", "to": "alice", "body": ["You are now an IRC operator"] } +{ + "command": "RPL_YOUREOPER", + "code": 381, + "to": "alice", + "body": ["You are now an IRC operator"] +} ``` **Behavior:** @@ -1055,7 +1155,8 @@ responses (e.g., target user's current client IP and hostname). returned. - On failure (wrong credentials or no o-line configured), `491 ERR_NOOPERHOST` is returned. -- Oper status persists for the session lifetime. There is no de-oper command. +- Oper status lasts until the session ends or the user drops it with user mode + `-o` (see [MODE](#mode--query-and-change-modes)). **IRC reference:** RFC 1459 §4.1.5 @@ -1071,11 +1172,13 @@ command. The kicked user and all channel members receive the KICK message. ``` The first element of `body` is the target nick, the second (optional) is the -reason. If no reason is provided, the kicker's nick is used as the default. +reason. If no reason is provided, the kicker's nick is used as the default; the +IRC listener uses the kicked user's nick instead. **Errors:** - `482` (ERR_CHANOPRIVSNEEDED) — kicker is not a channel operator +- `401` (ERR_NOSUCHNICK) — target nick does not exist - `441` (ERR_USERNOTINCHANNEL) — target is not in the channel - `403` (ERR_NOSUCHCHANNEL) — channel does not exist @@ -1116,72 +1219,75 @@ the full key distribution protocol. ### Numeric Reply Codes (S2C Only) Numeric replies follow IRC conventions from RFC 1459/2812. They are sent from -the server to the client (never C2S) and use 3-digit string codes in the -`command` field. +the server to the client (never C2S). Over the HTTP API, which the table below +describes, the `command` field holds the reply's name and `code` its number; the +IRC listener sends the usual 3-digit code. Over the HTTP API, 005 arrives named +`RPL_BOUNCE`, its name in RFC 2812. -| Code | Name | When Sent | Example | -| ----- | -------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` | -| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is neoirc, running version 0.1"]}` | -| `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` | -| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc","0.1","","ikmnostl"]}` | -| `005` | RPL_ISUPPORT | After session creation | `{"command":"005","to":"alice","params":["CHANTYPES=#","NICKLEN=32","PREFIX=(ov)@+","CHANMODES=b,k,Hl,imnst","NETWORK=neoirc"],"body":["are supported by this server"]}` | -| `221` | RPL_UMODEIS | In response to user MODE query | `{"command":"221","to":"alice","body":["+"]}` | -| `251` | RPL_LUSERCLIENT | On connect or LUSERS command | `{"command":"251","to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` | -| `252` | RPL_LUSEROP | On connect or LUSERS command | `{"command":"252","to":"alice","params":["0"],"body":["operator(s) online"]}` | -| `254` | RPL_LUSERCHANNELS | On connect or LUSERS command | `{"command":"254","to":"alice","params":["3"],"body":["channels formed"]}` | -| `255` | RPL_LUSERME | On connect or LUSERS command | `{"command":"255","to":"alice","body":["I have 5 clients and 1 servers"]}` | -| `311` | RPL_WHOISUSER | In response to WHOIS | `{"command":"311","to":"alice","params":["bob","bobident","host.example.com","*"],"body":["bob"]}` | -| `312` | RPL_WHOISSERVER | In response to WHOIS | `{"command":"312","to":"alice","params":["bob","neoirc"],"body":["neoirc server"]}` | -| `313` | RPL_WHOISOPERATOR | In WHOIS if target is oper | `{"command":"313","to":"alice","params":["bob"],"body":["is an IRC operator"]}` | -| `315` | RPL_ENDOFWHO | End of WHO response | `{"command":"315","to":"alice","params":["#general"],"body":["End of /WHO list"]}` | -| `318` | RPL_ENDOFWHOIS | End of WHOIS response | `{"command":"318","to":"alice","params":["bob"],"body":["End of /WHOIS list"]}` | -| `319` | RPL_WHOISCHANNELS | In response to WHOIS | `{"command":"319","to":"alice","params":["bob"],"body":["#general #dev"]}` | -| `338` | RPL_WHOISACTUALLY | In WHOIS when querier is oper | `{"command":"338","to":"alice","params":["bob","192.168.1.1"],"body":["is actually using host client.example.com"]}` | -| `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General discussion"]}` | -| `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` | -| `324` | RPL_CHANNELMODEIS | In response to channel MODE query | `{"command":"324","to":"alice","params":["#general","+n"]}` | -| `329` | RPL_CREATIONTIME | After channel MODE query | `{"command":"329","to":"alice","params":["#general","1709251200"]}` | -| `331` | RPL_NOTOPIC | Channel has no topic (on JOIN) | `{"command":"331","to":"alice","params":["#general"],"body":["No topic is set"]}` | -| `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` | -| `352` | RPL_WHOREPLY | In response to WHO | `{"command":"352","to":"alice","params":["#general","bobident","host.example.com","neoirc","bob","H"],"body":["0 bob"]}` | -| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["op1!op1@host1 alice!alice@host2 bob!bob@host3"]}` | -| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` | -| `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` | -| `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"375","to":"alice","body":["- neoirc-server Message of the Day -"]}` | -| `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"376","to":"alice","body":["End of /MOTD command"]}` | -| `381` | RPL_YOUREOPER | Successful OPER auth | `{"command":"381","to":"alice","body":["You are now an IRC operator"]}` | -| `401` | ERR_NOSUCHNICK | DM to nonexistent nick | `{"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}` | -| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` | -| `421` | ERR_UNKNOWNCOMMAND | Unrecognized command | `{"command":"421","to":"alice","params":["FOO"],"body":["Unknown command"]}` | -| `432` | ERR_ERRONEUSNICKNAME | Invalid nick format | `{"command":"432","to":"alice","params":["bad nick!"],"body":["Erroneous nickname"]}` | -| `433` | ERR_NICKNAMEINUSE | NICK to taken nick | `{"command":"433","to":"*","params":["alice"],"body":["Nickname is already in use"]}` | -| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` | -| `461` | ERR_NEEDMOREPARAMS | Missing required fields | `{"command":"461","to":"alice","params":["JOIN"],"body":["Not enough parameters"]}` | -| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` | -| `491` | ERR_NOOPERHOST | Failed OPER auth | `{"command":"491","to":"alice","body":["No O-lines for your host"]}` | - -**Note:** Numeric replies are now implemented. All IRC command responses -(success and error) are delivered as numeric replies through the message queue. -HTTP error codes are reserved for transport-level issues (auth failures, -malformed requests, server errors). The `params` field in the message envelope -carries IRC-style parameters (e.g., channel name, target nick). +| Code | Name | When Sent | Example | +| ----- | -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `001` | RPL_WELCOME | After session creation or login, on MOTD | `{"command":"RPL_WELCOME","code":1,"to":"alice","body":["Welcome to the network, alice"]}` | +| `002` | RPL_YOURHOST | After session creation or login, on MOTD | `{"command":"RPL_YOURHOST","code":2,"to":"alice","body":["Your host is neoirc, running version 0.1"]}` | +| `003` | RPL_CREATED | After session creation or login, on MOTD | `{"command":"RPL_CREATED","code":3,"to":"alice","body":["This server was created 2026-02-10"]}` | +| `004` | RPL_MYINFO | After session creation or login, on MOTD | `{"command":"RPL_MYINFO","code":4,"to":"alice","params":["neoirc","0.1","","ikmnostl"]}` | +| `005` | RPL_ISUPPORT | After session creation or login, on MOTD | `{"command":"RPL_BOUNCE","code":5,"to":"alice","params":["CHANTYPES=#","NICKLEN=32","PREFIX=(ov)@+","CHANMODES=b,k,Hl,imnst","NETWORK=neoirc","CASEMAPPING=ascii"],"body":["are supported by this server"]}` | +| `221` | RPL_UMODEIS | In response to user MODE | `{"command":"RPL_UMODEIS","code":221,"to":"alice","body":["+"]}` | +| `251` | RPL_LUSERCLIENT | On connect, MOTD or LUSERS | `{"command":"RPL_LUSERCLIENT","code":251,"to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` | +| `252` | RPL_LUSEROP | On connect, MOTD or LUSERS | `{"command":"RPL_LUSEROP","code":252,"to":"alice","params":["0"],"body":["operator(s) online"]}` | +| `254` | RPL_LUSERCHANNELS | On connect, MOTD or LUSERS | `{"command":"RPL_LUSERCHANNELS","code":254,"to":"alice","params":["3"],"body":["channels formed"]}` | +| `255` | RPL_LUSERME | On connect, MOTD or LUSERS | `{"command":"RPL_LUSERME","code":255,"to":"alice","body":["I have 5 clients and 1 servers"]}` | +| `311` | RPL_WHOISUSER | In response to WHOIS | `{"command":"RPL_WHOISUSER","code":311,"to":"alice","params":["bob","bobident","host.example.com","*"],"body":["bob"]}` | +| `312` | RPL_WHOISSERVER | In response to WHOIS | `{"command":"RPL_WHOISSERVER","code":312,"to":"alice","params":["bob","neoirc"],"body":["neoirc server"]}` | +| `313` | RPL_WHOISOPERATOR | In WHOIS if target is oper | `{"command":"RPL_WHOISOPERATOR","code":313,"to":"alice","params":["bob"],"body":["is an IRC operator"]}` | +| `315` | RPL_ENDOFWHO | End of WHO response | `{"command":"RPL_ENDOFWHO","code":315,"to":"alice","params":["#general"],"body":["End of /WHO list"]}` | +| `318` | RPL_ENDOFWHOIS | End of WHOIS response | `{"command":"RPL_ENDOFWHOIS","code":318,"to":"alice","params":["bob"],"body":["End of /WHOIS list"]}` | +| `319` | RPL_WHOISCHANNELS | In response to WHOIS | `{"command":"RPL_WHOISCHANNELS","code":319,"to":"alice","params":["bob"],"body":["#general #dev"]}` | +| `322` | RPL_LIST | In response to LIST | `{"command":"RPL_LIST","code":322,"to":"alice","params":["#general","5"],"body":["General discussion"]}` | +| `323` | RPL_LISTEND | End of LIST response | `{"command":"RPL_LISTEND","code":323,"to":"alice","body":["End of /LIST"]}` | +| `324` | RPL_CHANNELMODEIS | In response to channel MODE query | `{"command":"RPL_CHANNELMODEIS","code":324,"to":"alice","params":["#general","+nt"]}` | +| `329` | RPL_CREATIONTIME | After channel MODE query | `{"command":"RPL_CREATIONTIME","code":329,"to":"alice","params":["#general","1709251200"]}` | +| `331` | RPL_NOTOPIC | Channel has no topic (on JOIN) | `{"command":"RPL_NOTOPIC","code":331,"to":"alice","params":["#general"],"body":["No topic is set"]}` | +| `332` | RPL_TOPIC | On JOIN or TOPIC change | `{"command":"RPL_TOPIC","code":332,"to":"alice","params":["#general"],"body":["Welcome!"]}` | +| `338` | RPL_WHOISACTUALLY | In WHOIS when querier is oper | `{"command":"RPL_WHOISACTUALLY","code":338,"to":"alice","params":["bob","192.168.1.1"],"body":["is actually using host client.example.com"]}` | +| `352` | RPL_WHOREPLY | In response to WHO | `{"command":"RPL_WHOREPLY","code":352,"to":"alice","params":["#general","bobident","host.example.com","neoirc","bob","H"],"body":["0 bob"]}` | +| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"RPL_NAMREPLY","code":353,"to":"alice","params":["=","#general"],"body":["@op1!op1@host1 alice!alice@host2 bob!bob@host3"]}` | +| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"RPL_ENDOFNAMES","code":366,"to":"alice","params":["#general"],"body":["End of /NAMES list"]}` | +| `367` | RPL_BANLIST | One ban, on a ban list query | `{"command":"RPL_BANLIST","code":367,"to":"alice","params":["#general","*!*@*.example.com","alice","1709251200"]}` | +| `368` | RPL_ENDOFBANLIST | End of a ban list | `{"command":"RPL_ENDOFBANLIST","code":368,"to":"alice","params":["#general"],"body":["End of channel ban list"]}` | +| `372` | RPL_MOTD | MOTD line | `{"command":"RPL_MOTD","code":372,"to":"alice","body":["- Welcome to the server"]}` | +| `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"RPL_MOTDSTART","code":375,"to":"alice","body":["- neoirc Message of the Day -"]}` | +| `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"RPL_ENDOFMOTD","code":376,"to":"alice","body":["End of /MOTD command."]}` | +| `381` | RPL_YOUREOPER | Successful OPER auth | `{"command":"RPL_YOUREOPER","code":381,"to":"alice","body":["You are now an IRC operator"]}` | +| `401` | ERR_NOSUCHNICK | Nick does not exist | `{"command":"ERR_NOSUCHNICK","code":401,"to":"alice","params":["bob"],"body":["No such nick"]}` | +| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"ERR_NOSUCHCHANNEL","code":403,"to":"alice","params":["#nope"],"body":["No such channel"]}` | +| `421` | ERR_UNKNOWNCOMMAND | Unrecognized command | `{"command":"ERR_UNKNOWNCOMMAND","code":421,"to":"alice","params":["FOO"],"body":["Unknown command"]}` | +| `432` | ERR_ERRONEUSNICKNAME | Invalid nick format | `{"command":"ERR_ERRONEUSNICKNAME","code":432,"to":"alice","params":["bad nick!"],"body":["Erroneous nickname"]}` | +| `433` | ERR_NICKNAMEINUSE | NICK to taken nick | `{"command":"ERR_NICKNAMEINUSE","code":433,"to":"alice","params":["bob"],"body":["Nickname is already in use"]}` | +| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"ERR_NOTONCHANNEL","code":442,"to":"alice","params":["#general"],"body":["You're not on that channel"]}` | +| `461` | ERR_NEEDMOREPARAMS | Missing required fields | `{"command":"ERR_NEEDMOREPARAMS","code":461,"to":"alice","params":["JOIN"],"body":["Not enough parameters"]}` | +| `472` | ERR_UNKNOWNMODE | Channel mode not accepted | `{"command":"ERR_UNKNOWNMODE","code":472,"to":"alice","params":["+q"],"body":["is unknown mode char to me"]}` | +| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"ERR_CHANOPRIVSNEEDED","code":482,"to":"alice","params":["#general"],"body":["You're not channel operator"]}` | +| `491` | ERR_NOOPERHOST | Failed OPER auth | `{"command":"ERR_NOOPERHOST","code":491,"to":"alice","body":["No O-lines for your host"]}` | +| `501` | ERR_UMODEUNKNOWNFLAG | User mode not accepted | `{"command":"ERR_UMODEUNKNOWNFLAG","code":501,"to":"alice","body":["Unknown MODE flag"]}` | +| `502` | ERR_USERSDONTMATCH | User MODE for another nick | `{"command":"ERR_USERSDONTMATCH","code":502,"to":"alice","body":["Can't change mode for other users"]}` | ### Channel Modes -Inspired by IRC, simplified: +Inspired by IRC, simplified. See [MODE](#mode--query-and-change-modes) for how +to set them; `b`, `k` and `l` can be set only over the HTTP API, and `n` only on +the IRC listener. -| Mode | Name | Meaning | Status | -| ---- | ----------- | ----------------------------------------------------------------------------------------------------- | ------------ | -| `+b` | Ban | Prevents matching hostmasks from joining or sending (parameter: `nick!user@host` mask with wildcards) | **Enforced** | -| `+i` | Invite-only | Only invited users can join; use `INVITE nick #channel` to invite | **Enforced** | -| `+k` | Channel key | Requires a password to join (parameter: key string) | **Enforced** | -| `+l` | User limit | Maximum number of members allowed in the channel (parameter: integer) | **Enforced** | -| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** | -| `+n` | No external | Only channel members can send messages to the channel | **Enforced** | -| `+s` | Secret | Channel hidden from LIST and WHOIS for non-members | **Enforced** | -| `+t` | Topic lock | Only operators can change the topic (default: ON) | **Enforced** | -| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | **Enforced** | +| Mode | Name | Meaning | Status | +| ---- | ----------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `+b` | Ban | Prevents matching hostmasks from joining or sending (parameter: `nick!user@host` mask with wildcards) | **Enforced** | +| `+i` | Invite-only | Only invited users can join; use `INVITE` to invite | **Enforced**; only an invite made over the HTTP API lets a user in | +| `+k` | Channel key | Requires a password to join (parameter: key string) | **Enforced**; the IRC listener sends no key, so its users cannot join a channel that has one | +| `+l` | User limit | Maximum number of members allowed in the channel (parameter: integer) | **Enforced** | +| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** | +| `+n` | No external | Only channel members can send messages to the channel (default: ON) | Shown only: non-members can never send to a channel, whether or not `+n` is set | +| `+s` | Secret | Channel hidden from LIST and WHOIS for non-members | **Enforced** by `LIST` and `WHOIS` over the HTTP API; not by `GET /api/v1/channels` or the IRC listener | +| `+t` | Topic lock | Only operators can change the topic (default: ON) | **Enforced** | +| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | **Enforced** over the HTTP API only | **User channel modes (set per-user per-channel):** @@ -1194,58 +1300,72 @@ Inspired by IRC, simplified: automatically receives `+o` operator status. **Ban system (+b):** Operators can ban users by hostmask pattern with wildcard -matching (`*` and `?`). `MODE #channel +b` with no argument lists current bans. -Bans prevent both joining and sending messages. +matching (`*` and `?`, ignoring letter case). `+b` with no mask lists current +bans. Bans prevent both joining and sending messages. -``` -MODE #channel +b *!*@*.example.com — ban all users from example.com -MODE #channel -b *!*@*.example.com — remove the ban -MODE #channel +b — list all bans (RPL_BANLIST 367/368) +```json +{"command": "MODE", "to": "#channel", "body": ["+b", "*!*@*.example.com"]} +{"command": "MODE", "to": "#channel", "body": ["-b", "*!*@*.example.com"]} +{"command": "MODE", "to": "#channel", "body": ["+b"]} ``` -**Invite-only (+i):** When set, users must be invited by an operator before -joining. The `INVITE` command records an invite that is consumed on JOIN. +These ban all users from example.com, remove that ban, and list all bans +(RPL_BANLIST 367, RPL_ENDOFBANLIST 368). -``` -MODE #channel +i — set invite-only -INVITE nick #channel — invite a user (operator only on +i channels) +**Invite-only (+i):** When set, users must be invited before joining. Over the +HTTP API, `INVITE` records an invite that is consumed on JOIN; any member can +invite, and on a `+i` channel only an operator can. `INVITE` on the IRC listener +tells the user about the invite but records none. + +```json +{"command": "MODE", "to": "#channel", "body": ["+i"]} +{"command": "INVITE", "body": ["nick", "#channel"]} ``` -**Channel key (+k):** Requires a password to join the channel. +**Channel key (+k):** Requires a password to join the channel. Over the HTTP API +the key is `body[0]` of the `JOIN`. -``` -MODE #channel +k secretpass — set a channel key -MODE #channel -k * — remove the key -JOIN #channel secretpass — join with key +```json +{"command": "MODE", "to": "#channel", "body": ["+k", "secretpass"]} +{"command": "MODE", "to": "#channel", "body": ["-k"]} +{"command": "JOIN", "to": "#channel", "body": ["secretpass"]} ``` **User limit (+l):** Caps the number of members in the channel. -``` -MODE #channel +l 50 — set limit to 50 members -MODE #channel -l — remove the limit +```json +{"command": "MODE", "to": "#channel", "body": ["+l", "50"]} +{"command": "MODE", "to": "#channel", "body": ["-l"]} ``` -**Secret (+s):** Hides the channel from `LIST` for non-members and from `WHOIS` -channel lists when the querier is not in the same channel. +**Secret (+s):** The HTTP API's `LIST` hides the channel from non-members, and +its `WHOIS` leaves it out of the channel list when the querier is not in the +same channel. + +**Join errors:** a user who is banned gets ERR_BANNEDFROMCHAN (474), one not +invited to a `+i` channel ERR_INVITEONLYCHAN (473), one with a missing or wrong +key ERR_BADCHANNELKEY (475), and one joining a full channel ERR_CHANNELISFULL +(471). None of these apply to the user who creates the channel. **KICK command:** Channel operators can remove users with `KICK #channel nick [:reason]`. The kicked user and all channel members receive the KICK message. -**NOTICE:** Follows RFC 2812 — NOTICE never triggers auto-replies (including -RPL_AWAY), and skips hashcash validation on +H channels (servers and services -use NOTICE). +**NOTICE:** Over the HTTP API, a NOTICE gets no RPL_AWAY and no hashcash check +on `+H` channels, but it gets the same error numerics as PRIVMSG (411, 412, 401, +403, 404). The IRC listener handles NOTICE exactly as PRIVMSG, so a NOTICE to a +user who is away gets RPL_AWAY there. -**ISUPPORT:** The server advertises `PREFIX=(ov)@+` and `CHANMODES=b,k,Hl,imnst` -in RPL_ISUPPORT (005). +**ISUPPORT:** The server advertises `PREFIX=(ov)@+` in RPL_ISUPPORT (005), with +`CHANMODES=b,k,Hl,imnst` over the HTTP API and `CHANMODES=,,H,imnst` on the IRC +listener. ### Per-Channel Hashcash (Anti-Spam) -Channels can require hashcash proof-of-work for every `PRIVMSG`. This is an -anti-spam mechanism: channel operators set a difficulty level, and clients must -compute a proof-of-work stamp bound to the specific channel and message before -sending. +Channels can require hashcash proof-of-work for every `PRIVMSG` sent over the +HTTP API; the IRC listener does not check it. This is an anti-spam mechanism: +channel operators set a difficulty level, and clients must compute a +proof-of-work stamp bound to the specific channel and message before sending. **Setting the requirement:** @@ -1304,6 +1424,9 @@ the format: { "error": "human-readable error message" } ``` +A request to an authenticated endpoint without a valid cookie gets `401` with +`{"error": "not registered", "numeric": 451}`. + ### POST /api/v1/session — Create Session Create a new user session. This is the entry point for all clients. @@ -1323,16 +1446,18 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field. } ``` -| Field | Type | Required | Constraints | -| ----------- | ------ | ----------- | -------------------------------------------------------------- | -| `nick` | string | Yes | 1–32 characters, must be unique on the server | -| `username` | string | No | 1–32 characters, IRC ident-style. Defaults to nick if omitted. | -| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) | +| Field | Type | Required | Constraints | +| ----------- | ------ | ----------- | -------------------------------------------------------------------------- | +| `nick` | string | Yes | A valid nick (see [Nick Semantics](#nick-semantics)), unique on the server | +| `username` | string | No | 1–32 characters, IRC ident-style. Defaults to nick if omitted. | +| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) | The `username` field sets the user portion of the IRC hostmask -(`nick!user@host`). The hostname is automatically resolved via reverse DNS of -the connecting client's IP address at session creation time. Together these form -the hostmask used in WHOIS, WHO, and future ban matching (`+b`). +(`nick!user@host`). The hostname is the reverse DNS name of the connecting +client's IP address, looked up at session creation time, or the IP address +itself when the lookup gives no name; every user can see it (see +[Hostmask](#hostmask-nickuserhost)). Together these form the hostmask used in +WHOIS, WHO, NAMES, and ban matching (`+b`). **Response:** `201 Created` @@ -1340,7 +1465,7 @@ The response sets an `neoirc_auth` HttpOnly cookie containing an opaque auth value. The JSON body does **not** include the auth credential. ``` -Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict +Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; Secure; SameSite=Strict ``` ```json @@ -1369,7 +1494,8 @@ Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict | Status | Error | When | | ------ | --------------------------------- | ------------------------------------------------------------------ | -| 400 | `nick must be 1-32 characters` | Empty or too-long nick | +| 400 | `invalid request body` | Malformed JSON, or a body over `MAX_MESSAGE_SIZE` bytes | +| 400 | `invalid nick format` | Nick is not a valid nick | | 400 | `invalid username format` | Username doesn't match allowed format | | 402 | `hashcash proof-of-work required` | Missing `pow_token` field in request body when hashcash is enabled | | 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) | @@ -1387,9 +1513,9 @@ curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \ ### POST /api/v1/login — Login to Account Authenticate with a nick and password (set via the PASS IRC command). Creates a -new client for the existing session, preserving channel memberships and message -queues. This is how multi-client access works: each login adds a new client to -the session with its own auth cookie and message delivery queue. +new client for the existing session, preserving channel memberships. This is how +multi-client access works: each login adds a new client to the session with its +own auth cookie and message delivery queue. On successful login, the server enqueues MOTD messages and synthetic channel state (JOIN + TOPIC + NAMES for each channel the session belongs to) into the @@ -1424,10 +1550,12 @@ The response sets an `neoirc_auth` HttpOnly cookie for the new client. **Errors:** -| Status | Error | When | -| ------ | ---------------------------- | -------------------------------------------------------------- | -| 400 | `nick and password required` | Missing nick or password | -| 401 | `invalid credentials` | Wrong password, nick not found, or session has no password set | +| Status | Error | When | +| ------ | ------------------------------------------ | --------------------------------------------------------------------------------------------------------- | +| 400 | `invalid request body` | Malformed JSON, or a body over `MAX_MESSAGE_SIZE` bytes | +| 400 | `nick and password required` | Missing nick or password | +| 401 | `invalid credentials` | Wrong password, nick not found, or session has no password set | +| 429 | `too many login attempts, try again later` | Per-IP rate limit exceeded (see [Login Rate Limiting](#login-rate-limiting)); comes with `Retry-After: 1` | **curl example:** @@ -1512,8 +1640,8 @@ real-time endpoint — clients call it in a loop. "command": "JOIN", "from": "bob", "to": "#general", - "body": [], - "ts": "2026-02-10T20:00:00.000000000Z", + "body": ["#general"], + "ts": "2026-02-10T20:00:00.123456789Z", "meta": {} }, { @@ -1522,7 +1650,7 @@ real-time endpoint — clients call it in a loop. "from": "alice", "to": "#general", "body": ["hello world"], - "ts": "2026-02-10T20:00:01.000000000Z", + "ts": "2026-02-10T20:00:01.234567891Z", "meta": {} } ], @@ -1530,10 +1658,10 @@ real-time endpoint — clients call it in a loop. } ``` -| Field | Type | Description | -| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------- | -| `messages` | array | Array of IRC message envelopes (see [Protocol Specification](#protocol-specification)). Empty array if no messages. | -| `last_id` | integer | Queue cursor. Pass this as `after` in the next request. | +| Field | Type | Description | +| ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `messages` | array | Array of IRC message envelopes (see [Protocol Specification](#protocol-specification)), at most 100 per response. Empty array if no messages. | +| `last_id` | integer | Queue cursor. Pass this as `after` in the next request. | **Long-poll behavior:** @@ -1579,12 +1707,16 @@ reference with all required and optional fields. | ---------- | --------------- | -------- | --------------- | | `PRIVMSG` | `to`, `body` | `meta` | 200 OK | | `NOTICE` | `to`, `body` | `meta` | 200 OK | -| `JOIN` | `to` | | 200 OK | +| `JOIN` | `to` | `body` | 200 OK | | `PART` | `to` | `body` | 200 OK | | `NICK` | `body` | | 200 OK | | `PASS` | `body` | | 200 OK | | `TOPIC` | `to`, `body` | | 200 OK | | `MODE` | `to` | `body` | 200 OK | +| `INVITE` | `body` | | 200 OK | +| `KICK` | `to`, `body` | | 200 OK | +| `AWAY` | | `body` | 200 OK | +| `MOTD` | | | 200 OK | | `NAMES` | `to` | | 200 OK | | `LIST` | | | 200 OK | | `WHOIS` | `to` or `body` | | 200 OK | @@ -1601,77 +1733,101 @@ reference with all required and optional fields. | `QUIT` | | `body` | 200 OK | | `PING` | | | 200 OK | -All IRC commands return HTTP 200 OK. IRC-level success and error responses are -delivered as **numeric replies** through the message queue (see -[Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are -reserved for transport-level problems: malformed JSON (400), missing/invalid -auth cookies (401), and server errors (500). +All IRC commands return HTTP 200 OK, usually with `{"status": "error"}` when the +reply is an error numeric (a WHOIS for an unknown nick still gets +`{"status": "ok"}`), and otherwise a status of the command's own (see each +command). **Numeric replies**, for success and for errors, are delivered through +the message queue (see [Numeric Reply Codes](#numeric-reply-codes-s2c-only)); a +command that has none, such as a successful `PRIVMSG`, is answered by the HTTP +response alone, and `PING`'s `PONG` is the HTTP response body. HTTP error codes +(4xx/5xx) are reserved for transport-level problems: malformed JSON (400), +missing/invalid auth cookies (401), and server errors (500). **HTTP errors (transport-level only):** -| Status | Error | When | -| ------ | ----------------- | ------------------------------- | -| 400 | `invalid request` | Malformed JSON or empty command | -| 401 | `unauthorized` | Missing or invalid auth cookie | -| 500 | `internal error` | Server-side failure | +| Status | Error | When | +| ------ | ---------------------- | ------------------------------------------------------- | +| 400 | `invalid request body` | Malformed JSON, or a body over `MAX_MESSAGE_SIZE` bytes | +| 400 | `command required` | Empty command | +| 401 | `not registered` | Missing or invalid auth cookie | +| 500 | `internal error` | Server-side failure | **IRC numeric error replies (delivered via message queue):** -| Numeric | Name | When | -| ------- | -------------------- | -------------------------------------------- | -| 401 | ERR_NOSUCHNICK | DM target nick doesn't exist | -| 403 | ERR_NOSUCHCHANNEL | Target channel doesn't exist or invalid name | -| 421 | ERR_UNKNOWNCOMMAND | Unrecognized command | -| 432 | ERR_ERRONEUSNICKNAME | Invalid nickname format | -| 433 | ERR_NICKNAMEINUSE | NICK target is taken | -| 442 | ERR_NOTONCHANNEL | Not a member of the target channel | -| 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) | -| 481 | ERR_NOPRIVILEGES | KILL or WALLOPS by a non-operator | -| 483 | ERR_CANTKILLSERVER | KILL of yourself | -| 491 | ERR_NOOPERHOST | Failed OPER authentication | -| 501 | ERR_UMODEUNKNOWNFLAG | User MODE string not accepted | -| 502 | ERR_USERSDONTMATCH | User MODE for a nick other than your own | +| Numeric | Name | When | +| ------- | -------------------- | ----------------------------------------------------------------- | +| 401 | ERR_NOSUCHNICK | Target nick doesn't exist | +| 403 | ERR_NOSUCHCHANNEL | Target channel doesn't exist or invalid name | +| 404 | ERR_CANNOTSENDTOCHAN | Message to a channel not allowed (not a member, `+b`, `+m`, `+H`) | +| 411 | ERR_NORECIPIENT | PRIVMSG or NOTICE without `to` | +| 412 | ERR_NOTEXTTOSEND | PRIVMSG or NOTICE without `body` | +| 421 | ERR_UNKNOWNCOMMAND | Unrecognized command | +| 432 | ERR_ERRONEUSNICKNAME | Invalid nickname format | +| 433 | ERR_NICKNAMEINUSE | NICK target is taken | +| 441 | ERR_USERNOTINCHANNEL | KICK or MODE `+o`/`+v` target is not in the channel | +| 442 | ERR_NOTONCHANNEL | Not a member of the target channel | +| 443 | ERR_USERONCHANNEL | INVITE of a user already in the channel | +| 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) | +| 471 | ERR_CHANNELISFULL | JOIN to a full `+l` channel | +| 472 | ERR_UNKNOWNMODE | Channel MODE not accepted | +| 473 | ERR_INVITEONLYCHAN | JOIN to a `+i` channel without an invite | +| 474 | ERR_BANNEDFROMCHAN | JOIN by a banned user | +| 475 | ERR_BADCHANNELKEY | JOIN to a `+k` channel with a missing or wrong key | +| 481 | ERR_NOPRIVILEGES | KILL or WALLOPS by a non-operator | +| 482 | ERR_CHANOPRIVSNEEDED | Operator-only action by a non-operator | +| 483 | ERR_CANTKILLSERVER | KILL of yourself | +| 491 | ERR_NOOPERHOST | Failed OPER authentication | +| 501 | ERR_UMODEUNKNOWNFLAG | User MODE string not accepted | +| 502 | ERR_USERSDONTMATCH | User MODE for a nick other than your own | **IRC numeric success replies (delivered via message queue):** -| Numeric | Name | When | -| ------- | ----------------- | ------------------------------------ | -| 001 | RPL_WELCOME | Sent on session creation/login | -| 002 | RPL_YOURHOST | Sent on session creation/login | -| 003 | RPL_CREATED | Sent on session creation/login | -| 004 | RPL_MYINFO | Sent on session creation/login | -| 005 | RPL_ISUPPORT | Sent on session creation/login | -| 221 | RPL_UMODEIS | In response to user MODE | -| 251 | RPL_LUSERCLIENT | On connect or LUSERS command | -| 252 | RPL_LUSEROP | On connect or LUSERS command | -| 254 | RPL_LUSERCHANNELS | On connect or LUSERS command | -| 255 | RPL_LUSERME | On connect or LUSERS command | -| 256–259 | RPL_ADMINME etc. | ADMIN info | -| 302 | RPL_USERHOST | USERHOST reply | -| 311 | RPL_WHOISUSER | WHOIS user info | -| 312 | RPL_WHOISSERVER | WHOIS server info | -| 313 | RPL_WHOISOPERATOR | WHOIS target is oper | -| 315 | RPL_ENDOFWHO | End of WHO list | -| 318 | RPL_ENDOFWHOIS | End of WHOIS list | -| 319 | RPL_WHOISCHANNELS | WHOIS channels list | -| 338 | RPL_WHOISACTUALLY | WHOIS client IP (oper-only) | -| 351 | RPL_VERSION | VERSION reply | -| 371 | RPL_INFO | INFO line | -| 374 | RPL_ENDOFINFO | End of INFO | -| 391 | RPL_TIME | TIME reply | -| 322 | RPL_LIST | Channel in LIST response | -| 323 | RPL_LISTEND | End of LIST | -| 324 | RPL_CHANNELMODEIS | Channel mode query response | -| 329 | RPL_CREATIONTIME | Channel creation timestamp | -| 331 | RPL_NOTOPIC | Channel has no topic (on JOIN) | -| 332 | RPL_TOPIC | Channel topic (on JOIN, TOPIC set) | -| 352 | RPL_WHOREPLY | User in WHO response | -| 353 | RPL_NAMREPLY | Channel member list (on JOIN, NAMES) | -| 366 | RPL_ENDOFNAMES | End of NAMES list | -| 375 | RPL_MOTDSTART | Start of MOTD | -| 372 | RPL_MOTD | MOTD line | -| 376 | RPL_ENDOFMOTD | End of MOTD | -| 381 | RPL_YOUREOPER | Successful OPER authentication | +| Numeric | Name | When | +| ------- | ----------------- | --------------------------------------- | +| 001 | RPL_WELCOME | Sent on session creation/login and MOTD | +| 002 | RPL_YOURHOST | Sent on session creation/login and MOTD | +| 003 | RPL_CREATED | Sent on session creation/login and MOTD | +| 004 | RPL_MYINFO | Sent on session creation/login and MOTD | +| 005 | RPL_ISUPPORT | Sent on session creation/login and MOTD | +| 221 | RPL_UMODEIS | In response to user MODE | +| 251 | RPL_LUSERCLIENT | On connect, MOTD or LUSERS command | +| 252 | RPL_LUSEROP | On connect, MOTD or LUSERS command | +| 254 | RPL_LUSERCHANNELS | On connect, MOTD or LUSERS command | +| 255 | RPL_LUSERME | On connect, MOTD or LUSERS command | +| 256–259 | RPL_ADMINME etc. | ADMIN info | +| 301 | RPL_AWAY | PRIVMSG to a user who is away | +| 302 | RPL_USERHOST | USERHOST reply | +| 305 | RPL_UNAWAY | AWAY with no message | +| 306 | RPL_NOWAWAY | AWAY with a message | +| 311 | RPL_WHOISUSER | WHOIS user info | +| 312 | RPL_WHOISSERVER | WHOIS server info | +| 313 | RPL_WHOISOPERATOR | WHOIS target is oper | +| 315 | RPL_ENDOFWHO | End of WHO list | +| 317 | RPL_WHOISIDLE | WHOIS idle time and signon time | +| 318 | RPL_ENDOFWHOIS | End of WHOIS list | +| 319 | RPL_WHOISCHANNELS | WHOIS channels list | +| 322 | RPL_LIST | Channel in LIST response | +| 323 | RPL_LISTEND | End of LIST | +| 324 | RPL_CHANNELMODEIS | Channel mode query, `+H`/`-H` change | +| 329 | RPL_CREATIONTIME | Channel creation timestamp | +| 331 | RPL_NOTOPIC | Channel has no topic (on JOIN) | +| 332 | RPL_TOPIC | Channel topic (on JOIN, TOPIC set) | +| 333 | RPL_TOPICWHOTIME | Who set the topic, and when | +| 338 | RPL_WHOISACTUALLY | WHOIS client IP (oper-only) | +| 341 | RPL_INVITING | INVITE sent | +| 351 | RPL_VERSION | VERSION reply | +| 352 | RPL_WHOREPLY | User in WHO response | +| 353 | RPL_NAMREPLY | Channel member list (on JOIN, NAMES) | +| 366 | RPL_ENDOFNAMES | End of NAMES list | +| 367 | RPL_BANLIST | One ban, on a ban list query | +| 368 | RPL_ENDOFBANLIST | End of a ban list | +| 371 | RPL_INFO | INFO line | +| 372 | RPL_MOTD | MOTD line | +| 374 | RPL_ENDOFINFO | End of INFO | +| 375 | RPL_MOTDSTART | Start of MOTD | +| 376 | RPL_ENDOFMOTD | End of MOTD | +| 381 | RPL_YOUREOPER | Successful OPER authentication | +| 391 | RPL_TIME | TIME reply | ### GET /api/v1/history — Message History @@ -1684,7 +1840,11 @@ Fetch historical messages for a channel. Returns messages in chronological order | -------- | ------- | ---------- | -------------------------------------------------------------------------------- | | `target` | string | (required) | Channel name (e.g., `#general`) | | `before` | integer | `0` | Return only messages with DB ID < this value (for pagination). `0` means latest. | -| `limit` | integer | `50` | Maximum messages to return. | +| `limit` | integer | `50` | Maximum messages to return, 1–500; any other value gives 50. | + +`target` may also be your own nick, which returns the DMs sent to you. Errors: +`400` `target required`, `404` `channel not found`, `403` +`not a member of this channel`, and `403` `forbidden` for any other nick. **Response:** `200 OK` @@ -1696,7 +1856,7 @@ Fetch historical messages for a channel. Returns messages in chronological order "from": "alice", "to": "#general", "body": ["first message"], - "ts": "2026-02-10T19:00:00.000000000Z", + "ts": "2026-02-10T19:00:00.123456789Z", "meta": {} }, { @@ -1705,7 +1865,7 @@ Fetch historical messages for a channel. Returns messages in chronological order "from": "bob", "to": "#general", "body": ["second message"], - "ts": "2026-02-10T19:01:00.000000000Z", + "ts": "2026-02-10T19:01:00.234567891Z", "meta": {} } ] @@ -1748,11 +1908,20 @@ List members of a channel. The `{name}` parameter is the channel name ```json [ - { "id": 1, "nick": "alice", "lastSeen": "2026-02-10T20:00:00Z" }, - { "id": 2, "nick": "bob", "lastSeen": "2026-02-10T19:55:00Z" } + { + "id": 1, + "nick": "alice", + "username": "alice", + "hostname": "host.example.com", + "isOperator": true, + "isVoiced": false, + "lastSeen": "2026-02-10T20:00:00Z" + } ] ``` +An unknown channel gets `404` `channel not found`. + **curl example:** ```bash @@ -1779,9 +1948,9 @@ The response clears the `neoirc_auth` cookie. **Errors:** -| Status | Error | When | -| ------ | -------------- | ------------------------------ | -| 401 | `unauthorized` | Missing or invalid auth cookie | +| Status | Error | When | +| ------ | ---------------- | ------------------------------ | +| 401 | `not registered` | Missing or invalid auth cookie | **curl example:** @@ -1847,7 +2016,7 @@ health status and runtime statistics. ```json { "status": "ok", - "now": "2024-01-15T12:00:00.000000000Z", + "now": "2024-01-15T12:00:00.123456789Z", "uptimeSeconds": 3600, "uptimeHuman": "1h0m0s", "version": "0.1.0", @@ -1863,15 +2032,15 @@ health status and runtime statistics. } ``` -| Field | Description | -| ---------------------- | ----------------------------------------------------- | -| `sessions` | Current number of active sessions | -| `clients` | Current number of connected clients | -| `queuedLines` | Total entries in client output queues | -| `channels` | Current number of channels | -| `connectionsSinceBoot` | Total client connections since server start | -| `sessionsSinceBoot` | Total sessions created since server start | -| `messagesSinceBoot` | Total PRIVMSG/NOTICE messages sent since server start | +| Field | Description | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `sessions` | Current number of active sessions | +| `clients` | Current number of connected clients | +| `queuedLines` | Total entries in client output queues | +| `channels` | Current number of channels | +| `connectionsSinceBoot` | Session creations and logins over the HTTP API since server start | +| `sessionsSinceBoot` | Sessions created over the HTTP API since server start | +| `messagesSinceBoot` | PRIVMSG/NOTICE commands with a target and body received over the HTTP API since server start, whether delivered or refused | --- @@ -1892,7 +2061,7 @@ Alice Server Bob │ │ 4. Enqueue for bob │ │ │ 5. Notify alice broker │ │ │ 6. Notify bob broker │ - │ 201 {"status":"sent"} │ │ + │ 200 {"status":"sent"} │ │ │<───────────────────────│ │ │ │ │ │ GET /messages?after=N │ GET /messages?after=M │ @@ -1918,7 +2087,7 @@ Alice Server Bob │ │ 4. Enqueue for alice │ │ │ (echo to sender) │ │ │ 5. Notify both │ - │ 201 {"status":"sent"} │ │ + │ 200 {"status":"sent"} │ │ │<───────────────────────│ │ │ │ │ │ (alice sees her own DM │ (bob sees DM from │ @@ -1953,6 +2122,10 @@ Messages support optional cryptographic signatures for integrity verification. Servers relay signatures verbatim without verifying them — verification is purely a client-side concern. +**Status:** this section documents the design. `PUBKEY` and message signing are +not yet implemented (see [Roadmap](#roadmap)); today the server relays `meta`, +and any signature in it, verbatim. + ### Canonicalization (RFC 8785 JCS) To produce a deterministic byte representation of a message for signing: @@ -2041,7 +2214,7 @@ has different canonical forms depending on escaping rules). "to": "#general", "body": ["this message is signed"], "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5", - "ts": "2026-02-10T20:00:00.000000000Z", + "ts": "2026-02-10T20:00:00.123456789Z", "meta": { "alg": "ed25519", "sig": "base64url-encoded-64-byte-signature" @@ -2066,8 +2239,9 @@ PGP/DKIM — the mail server sees everything, but signatures prove authenticity. entropy). Cookie values are hashed (SHA-256) before storage and validated on every request. Cookies are HttpOnly (no JavaScript access), SameSite=Strict (CSRF protection), and Secure (sent only over HTTPS). -- **Anonymous sessions**: `POST /api/v1/session` requires only a nick. No - password, instant access. The auth cookie is the sole credential. +- **Anonymous sessions**: `POST /api/v1/session` requires only a nick and, with + hashcash on (the default), a proof-of-work stamp. No password. The auth cookie + is the sole credential. - **Password-protected sessions**: The PASS IRC command sets a bcrypt-hashed password on the session. `POST /api/v1/login` authenticates against the stored hash and issues a new client cookie. @@ -2120,8 +2294,10 @@ PGP/DKIM — the mail server sees everything, but signatures prove authenticity. itself. - **CORS**: The server allows all origins with credentials (`Access-Control-Allow-Credentials: true`), reflecting the request Origin. - This enables cookie-based auth from cross-origin clients. Restrict origins in - production via reverse proxy configuration if needed. + Browsers still send the `SameSite=Strict` auth cookie only on requests from + the same site, so cookie-based auth works from other origins of the same site + but not from other sites. Restrict origins in production via reverse proxy + configuration if needed. - **Content-Security-Policy**: The server sets a strict CSP header on all responses, restricting resource loading to same-origin and disabling dangerous features (object embeds, framing, base tag injection). The embedded SPA works @@ -2224,64 +2400,102 @@ Postgres support is planned for larger deployments but not yet implemented. ### Schema The database schema is managed via embedded SQL migration files in -`internal/db/schema/`. Migrations run automatically on server start. +`internal/db/schema/`. Migrations run automatically on server start. `000.sql` +creates `schema_migrations`, which records the version of each migration +applied; `001_initial.sql` creates the tables below. **Current tables:** #### `sessions` -| Column | Type | Description | -| --------------- | -------- | ------------------------------------------------------------- | -| `id` | INTEGER | Primary key (auto-increment) | -| `uuid` | TEXT | Unique session UUID | -| `nick` | TEXT | Unique nick | -| `username` | TEXT | IRC ident/username portion of the hostmask (defaults to nick) | -| `hostname` | TEXT | Reverse DNS hostname of the connecting client IP | -| `ip` | TEXT | Real IP address of the session creator | -| `is_oper` | INTEGER | Server operator (o-line) status (0 = no, 1 = yes) | -| `password_hash` | TEXT | bcrypt hash (empty string for anonymous sessions) | -| `signing_key` | TEXT | Public signing key (empty string if unset) | -| `away_message` | TEXT | Away message (empty string if not away) | -| `created_at` | DATETIME | Session creation time | -| `last_seen` | DATETIME | Last API request or IRC listener command time | +| Column | Type | Description | +| --------------- | -------- | -------------------------------------------------------------------------------- | +| `id` | INTEGER | Primary key (auto-increment) | +| `uuid` | TEXT | Unique session UUID | +| `nick` | TEXT | Unique nick | +| `username` | TEXT | IRC ident/username portion of the hostmask (defaults to nick) | +| `hostname` | TEXT | Reverse DNS name of the creating client's IP, or the IP itself when it has none | +| `ip` | TEXT | Real IP address of the session creator | +| `is_oper` | INTEGER | Server operator, user mode `+o` (0 = no, 1 = yes) | +| `is_wallops` | INTEGER | User mode `+w`, receives `WALLOPS` (0 = no, 1 = yes) | +| `password_hash` | TEXT | bcrypt hash (empty string for anonymous sessions) | +| `signing_key` | TEXT | Public signing key; nothing sets or reads it yet | +| `away_message` | TEXT | Away message (empty string if not away) | +| `created_at` | DATETIME | Session creation time | +| `last_seen` | DATETIME | Last login, authenticated HTTP API request or IRC listener command (or creation) | Index on `(uuid)`. #### `clients` -| Column | Type | Description | -| ------------ | -------- | ---------------------------------------------------------- | -| `id` | INTEGER | Primary key (auto-increment) | -| `uuid` | TEXT | Unique client UUID | -| `session_id` | INTEGER | FK → sessions.id (cascade delete) | -| `token` | TEXT | Auth cookie value (SHA-256 hash of the 64-hex-char cookie) | -| `ip` | TEXT | Real IP address of this client connection | -| `hostname` | TEXT | Reverse DNS hostname of this client connection | -| `created_at` | DATETIME | Client creation time | -| `last_seen` | DATETIME | Last API request or IRC listener command time | +| Column | Type | Description | +| ------------ | -------- | ------------------------------------------------------------------------- | +| `id` | INTEGER | Primary key (auto-increment) | +| `uuid` | TEXT | Unique client UUID | +| `session_id` | INTEGER | FK → sessions.id (cascade delete) | +| `token` | TEXT | Auth cookie value (SHA-256 hash of the 64-hex-char cookie) | +| `ip` | TEXT | Real IP address of this client connection | +| `hostname` | TEXT | Reverse DNS name of this client's IP, or the IP itself when it has none | +| `created_at` | DATETIME | Client creation time | +| `last_seen` | DATETIME | Last authenticated HTTP API request or IRC listener command (or creation) | Indexes on `(token)` and `(session_id)`. #### `channels` -| Column | Type | Description | -| -------------- | -------- | -------------------------------------------------- | -| `id` | INTEGER | Primary key (auto-increment) | -| `name` | TEXT | Unique channel name (e.g., `#general`) | -| `topic` | TEXT | Channel topic (default empty) | -| `topic_set_by` | TEXT | Nick of the user who set the topic (default empty) | -| `topic_set_at` | DATETIME | When the topic was last set | -| `created_at` | DATETIME | Channel creation time | -| `updated_at` | DATETIME | Last modification time | +| Column | Type | Description | +| ----------------- | -------- | --------------------------------------------------------- | +| `id` | INTEGER | Primary key (auto-increment) | +| `name` | TEXT | Unique channel name (e.g., `#general`) | +| `topic` | TEXT | Channel topic (default empty) | +| `topic_set_by` | TEXT | Nick of the user who set the topic (default empty) | +| `topic_set_at` | DATETIME | When the topic was last set (null until then) | +| `hashcash_bits` | INTEGER | Mode `+H`: bits a `PRIVMSG` stamp must have (0 = off) | +| `is_moderated` | INTEGER | Mode `+m` (default 0) | +| `is_topic_locked` | INTEGER | Mode `+t` (default 1) | +| `is_invite_only` | INTEGER | Mode `+i` (default 0) | +| `is_secret` | INTEGER | Mode `+s` (default 0) | +| `is_no_external` | INTEGER | Mode `+n` (default 1) | +| `channel_key` | TEXT | Mode `+k`: the key needed to join (empty string = no key) | +| `user_limit` | INTEGER | Mode `+l`: maximum number of members (0 = no limit) | +| `created_at` | DATETIME | Channel creation time | +| `updated_at` | DATETIME | Last change to the topic or to a mode stored in this row | -#### `channel_members` +#### `channel_bans` + +| Column | Type | Description | +| ------------ | -------- | --------------------------------------------------- | +| `id` | INTEGER | Primary key (auto-increment) | +| `channel_id` | INTEGER | FK → channels.id (cascade delete) | +| `mask` | TEXT | Banned `nick!user@host` pattern (`*` and `?` match) | +| `set_by` | TEXT | Nick of the operator who set the ban | +| `created_at` | DATETIME | When the ban was set | + +Unique constraint on `(channel_id, mask)`. Index on `(channel_id)`. + +#### `channel_invites` | Column | Type | Description | | ------------ | -------- | --------------------------------- | | `id` | INTEGER | Primary key (auto-increment) | | `channel_id` | INTEGER | FK → channels.id (cascade delete) | | `session_id` | INTEGER | FK → sessions.id (cascade delete) | -| `joined_at` | DATETIME | When the user joined | +| `invited_by` | TEXT | Nick of the user who invited | +| `created_at` | DATETIME | When the invite was made | + +Unique constraint on `(channel_id, session_id)`. Index on `(channel_id)`. An +invite is deleted when the invited user joins the channel. + +#### `channel_members` + +| Column | Type | Description | +| ------------- | -------- | --------------------------------------------- | +| `id` | INTEGER | Primary key (auto-increment) | +| `channel_id` | INTEGER | FK → channels.id (cascade delete) | +| `session_id` | INTEGER | FK → sessions.id (cascade delete) | +| `is_operator` | INTEGER | Channel operator, mode `+o` (0 = no, 1 = yes) | +| `is_voiced` | INTEGER | Voice, mode `+v` (0 = no, 1 = yes) | +| `joined_at` | DATETIME | When the user joined | Unique constraint on `(channel_id, session_id)`. @@ -2301,6 +2515,18 @@ Unique constraint on `(channel_id, session_id)`. Indexes on `(msg_to, id)` and `(created_at)`. +#### `spent_hashcash` + +| Column | Type | Description | +| ------------ | -------- | ---------------------------------------------------- | +| `id` | INTEGER | Primary key (auto-increment) | +| `stamp_hash` | TEXT | Unique SHA-256 (hex) of a channel stamp already used | +| `created_at` | DATETIME | When the stamp was used | + +Index on `(created_at)`. Only the stamps of `+H` channel messages are recorded +here, and rows older than a year are pruned; session creation stamps are tracked +in memory. + #### `client_queues` | Column | Type | Description | @@ -2327,47 +2553,56 @@ issues) and simpler than UUIDs (integer comparison vs. string comparison). `QUIT` or when the last client logs out (`POST /api/v1/logout` with no remaining clients triggers session cleanup). There is no distinction between session types in the cleanup path — `handleQuit` and `cleanupUser` both call - `DeleteSession` unconditionally. Idle sessions are automatically expired after - `SESSION_IDLE_TIMEOUT` (default 30 days) — the server runs a background - cleanup loop that parts idle users from all channels, broadcasts QUIT, and - releases their nicks. + `DeleteSession` unconditionally. Idle clients are automatically removed after + `SESSION_IDLE_TIMEOUT` (default 30 days) without an authenticated HTTP API + request or, on the IRC listener, a command — the server runs a background + cleanup loop that, for a session left with no clients, parts the user from all + channels, broadcasts QUIT, and releases the nick. - **Clients**: Individual client auth cookies are invalidated on `POST /api/v1/logout`. A session can have multiple clients; removing one doesn't affect others. However, when the last client is removed (via logout), the entire session is deleted — the user is parted from all channels, QUIT is broadcast, and the nick is released. +The cleanup loop that does all of this pruning and expiry runs every half +`SESSION_IDLE_TIMEOUT`, but at least a minute apart (every 15 days by default), +the first time that long after the server starts, so rows can outlive their +maximum age by up to that interval. + --- ## Configuration -All configuration is via environment variables, read by +Configuration comes from environment variables, read by [Viper](https://github.com/spf13/viper). A `.env` file in the working directory -is also loaded automatically via [godotenv](https://github.com/joho/godotenv). +is also loaded automatically via [godotenv](https://github.com/joho/godotenv), +and Viper also reads a YAML file `neoirc.yaml` from `/etc/neoirc/` or +`$HOME/.config/neoirc/` if one exists, with the same names as keys; an +environment variable wins over the file. An environment variable set to the +empty string counts as unset, so the file's value or the default applies. -| Variable | Type | Default | Description | -| ---------------------- | ------ | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PORT` | int | `8080` | HTTP listen port | -| `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. | -| `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) | -| `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. | -| `SESSION_IDLE_TIMEOUT` | string | `720h` | Session idle timeout as a Go duration string (e.g. `720h`, `24h`). Sessions with no activity for this long are expired and the nick is released. Default is 30 days. | -| `QUEUE_MAX_AGE` | string | `720h` | Maximum age of client output queue entries as a Go duration string (e.g. `720h`, `24h`). Entries older than this are pruned. Default is 30 days. | -| `MAX_MESSAGE_SIZE` | int | `4096` | Maximum message body size in bytes (planned enforcement) | -| `LONG_POLL_TIMEOUT` | int | `15` | Default long-poll timeout in seconds (client can override via query param, server caps at 30) | -| `MOTD` | string | `""` | Message of the day, shown to clients via `GET /api/v1/server` | -| `SERVER_NAME` | string | `""` | Server display name. Defaults to hostname if empty. | -| `FEDERATION_KEY` | string | `""` | Shared key for server federation linking (planned) | -| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) | -| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. | -| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint | -| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. | -| `NEOIRC_OPER_NAME` | string | `""` | Server operator (o-line) username. Both name and password must be set to enable OPER. | -| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. | -| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. | -| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. | -| `IRC_LISTEN_ADDR` | string | `:6667` | TCP address for the traditional IRC protocol listener. Set to empty string to disable. | -| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) | +| Variable | Type | Default | Description | +| ---------------------- | ------ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PORT` | int | `8080` | HTTP listen port | +| `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. The SQLite driver ignores `_journal_mode`, so this does not turn on WAL mode. | +| `DEBUG` | bool | `false` | Switch logging to debug level | +| `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. | +| `SESSION_IDLE_TIMEOUT` | string | `720h` | Idle timeout as a Go duration string (e.g. `720h`, `24h`). Clients with no authenticated HTTP API request or IRC listener command for this long are removed, and a session left with none is expired and its nick released (see [Data Lifecycle](#data-lifecycle)). Default is 30 days. | +| `QUEUE_MAX_AGE` | string | `720h` | Maximum age of client output queue entries as a Go duration string (e.g. `720h`, `24h`). Entries older than this are pruned. Default is 30 days. | +| `MAX_MESSAGE_SIZE` | int | `4096` | Maximum request body size in bytes for `POST /api/v1/session`, `POST /api/v1/login` and `POST /api/v1/messages`; a larger body gets `400` `invalid request body` | +| `MOTD` | string | a built-in banner | Message of the day, sent to clients as MOTD numerics and shown via `GET /api/v1/server` | +| `SERVER_NAME` | string | `""` | Server display name. If empty, the server calls itself `neoirc` in replies and in `GET /api/v1/server` returns it empty. | +| `FEDERATION_KEY` | string | `""` | Shared key for server federation linking (planned) | +| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) | +| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. | +| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint | +| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. | +| `NEOIRC_OPER_NAME` | string | `""` | Server operator (o-line) username. Both name and password must be set to enable OPER. | +| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. | +| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. | +| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. | +| `IRC_LISTEN_ADDR` | string | `:6667` | TCP address for the traditional IRC protocol listener. Only an empty value in `neoirc.yaml` disables it; an empty environment variable does not. | +| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) | ### Example `.env` file @@ -2393,10 +2628,12 @@ and others. ### Configuration The IRC listener is **enabled by default** on `:6667`. To disable it, set -`IRC_LISTEN_ADDR` to an empty string: +`IRC_LISTEN_ADDR` to an empty string in `neoirc.yaml` (see +[Configuration](#configuration)); an empty environment variable counts as unset +and leaves it on `:6667`: -```bash -IRC_LISTEN_ADDR= +```yaml +IRC_LISTEN_ADDR: "" ``` ### Supported Commands @@ -2414,15 +2651,18 @@ IRC_LISTEN_ADDR= - **Wire format**: CR-LF delimited lines, max 512 bytes per line - **Connection registration**: Clients must send `NICK` and `USER` to register. An optional `PASS` before registration sets the session password (minimum 8 - characters). -- **CAP negotiation**: `CAP LS` and `CAP END` are silently handled for - compatibility with modern clients. No capabilities are advertised. -- **Channel prefixes**: Channels must start with `#`. If omitted, it's - automatically prepended. + characters). Registering creates a session without a hashcash stamp; closing + the connection ends the session, as `QUIT` does. +- **CAP negotiation**: `CAP LS` gets an empty capability list and `CAP END` is + ignored, for compatibility with modern clients. +- **Channel prefixes**: Channels must start with `#`. `JOIN` prepends it if + omitted; other commands use the name as given. - **First joiner**: The first user to join a channel is automatically granted operator status (`@`). -- **Channel modes**: `+m` (moderated), `+t` (topic lock), `+o` (operator), `+v` - (voice) +- **Channel modes**: `+i` (invite-only), `+m` (moderated), `+n` (no external), + `+s` (secret), `+t` (topic lock), `+H` (hashcash), `+o` (operator), `+v` + (voice); see [MODE](#mode--query-and-change-modes). `+b`, `+k` and `+l` can be + set only over the HTTP API. - **User modes**: `+o` (operator, set only via `OPER`), `+w` (receives `WALLOPS`). `MODE` for any nick other than your own is rejected with `ERR_USERSDONTMATCH` (502), for both queries and changes. Nick comparison is @@ -2437,8 +2677,22 @@ IRC_LISTEN_ADDR= Messages sent by IRC clients appear in channels visible to HTTP/JSON API clients and vice versa. The IRC listener and HTTP API share the same database, broker, -and session infrastructure. A user connected via IRC and a user connected via -the HTTP API can communicate in the same channels seamlessly. +and session infrastructure, so a user connected via IRC and a user connected via +the HTTP API can talk in the same channels, with these gaps today: + +- An IRC listener user receives only the first line of a multi-line `body`. +- A `+k`, `+l` or `+b` change made over the HTTP API reaches IRC listener + members without its key, limit or mask. +- The IRC listener's `JOIN` accepts any channel name, while over the HTTP API a + name that is not `#` followed by 1–63 letters, digits, `_` or `-` gets + ERR_NOSUCHCHANNEL (403), so HTTP API users cannot join such a channel. +- The IRC listener does not pass on a `PRIVMSG`, `NOTICE`, `JOIN`, `PART`, + `NICK` or `QUIT` whose sender's nick matches the user's own nick ignoring + case, so an IRC listener user `alice` never sees these from `Alice`, although + nicks are case-sensitive. + +https://git.eeqj.de/sneak/neoirc/issues/127 tracks the first three and +https://git.eeqj.de/sneak/neoirc/issues/128 the last. ### Docker Usage @@ -2488,7 +2742,9 @@ except `script/install-precommit`, which is `make hooks`, and `script/cibuild`, ### Docker (Recommended) -The Docker image contains a single static binary (`neoircd`) and nothing else. +The Docker image is Alpine with a single static binary (`neoircd`) and CA +certificates, run as the user `neoirc`, with a `HEALTHCHECK` on the health check +endpoint. ```bash # Build @@ -2514,7 +2770,7 @@ The Dockerfile is a five-stage build: 4. **builder**: Runs only once lint and test have passed; compiles static `neoircd` and `neoirc-cli` binaries with the real SPA assets from web-builder (CLI built to verify compilation, not included in final image) -5. **final**: Minimal Alpine image with only the `neoircd` binary +5. **final**: Minimal Alpine image with the `neoircd` binary and CA certificates ### Binary @@ -2525,7 +2781,7 @@ make build # Run ./bin/neoircd -# Listens on :8080, writes to /var/lib/neoirc/state.db +# Listens on :8080 (HTTP) and :6667 (IRC), writes to /var/lib/neoirc/state.db ``` ### Reverse Proxy (Production) @@ -2554,6 +2810,7 @@ server { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; proxy_read_timeout 60s; # Must be > long-poll timeout } } @@ -2564,13 +2821,14 @@ seconds to accommodate long-poll connections. ### SQLite Considerations -- **WAL mode** is enabled by default (`?_journal_mode=WAL` in the connection - string). This allows concurrent reads during writes. -- **Single writer**: SQLite allows only one writer at a time. For high-traffic - servers, Postgres support is planned. +- **Journal mode**: the default connection string carries `?_journal_mode=WAL`, + but the SQLite driver ignores that parameter, so the database runs in SQLite's + default rollback-journal mode, not WAL. +- **Single writer**: the server uses one database connection, so reads and + writes take turns. For high-traffic servers, Postgres support is planned. - **Backup**: The database is a single file. Back it up with - `sqlite3 /var/lib/neoirc/state.db ".backup backup.db"` or just copy the file - (safe with WAL mode). + `sqlite3 /var/lib/neoirc/state.db ".backup backup.db"`, or copy the file while + the server is stopped. - **Location**: By default, `state.db` is created in `/var/lib/neoirc/`. Use the `DBURL` env var to place it elsewhere. @@ -2595,6 +2853,10 @@ A complete client needs only four HTTP calls: ### Step-by-Step with curl +These examples, and the Python one below, assume a server run with +`NEOIRC_HASHCASH_BITS=0`. With the default of 20, session creation also needs a +`pow_token` (see [Hashcash Proof-of-Work](#hashcash-proof-of-work)). + ```bash # 1a. Create a session (cookie saved automatically with -c) curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \ @@ -2739,8 +3001,11 @@ Clients should handle these message commands from the queue: - **HTTP 401**: Auth cookie expired or invalid. Re-create session or re-login (if a password was set). -- **HTTP 404**: Channel or user not found. -- **HTTP 409**: Nick already taken (on session creation or NICK change). +- **HTTP 404**: Channel not found (`GET /api/v1/channels/{name}/members` and + `GET /api/v1/history`). +- **HTTP 409**: Nick already taken on session creation. A NICK change to a taken + nick, like every other command error, is a numeric in the queue + (ERR_NICKNAMEINUSE 433) with `{"status": "error"}` as the response. - **HTTP 400**: Malformed request. Check the `error` field in the response. - **Network errors**: Back off exponentially (1s, 2s, 4s, ..., max 30s). @@ -2770,9 +3035,10 @@ Clients should handle these message commands from the queue: ### Hashcash Proof-of-Work Session creation (`POST /api/v1/session`) requires a -[hashcash](https://en.wikipedia.org/wiki/Hashcash)-style proof-of-work token. -This is the primary defense against resource exhaustion — no CAPTCHAs, no -account registration, no IP-based rate limits that punish shared networks. +[hashcash](https://en.wikipedia.org/wiki/Hashcash)-style proof-of-work token +(registering on the IRC listener does not). This is the primary defense against +resource exhaustion — no CAPTCHAs, no account registration, no IP-based rate +limits that punish shared networks. ### How It Works @@ -2786,9 +3052,10 @@ account registration, no IP-based rate limits that punish shared networks. - Version is `1` - Claimed bits ≥ required bits - Resource matches the server name - - Date is within 48 hours (not expired, not too far in the future) + - Date is no more than 48 hours old and no more than 1 hour in the future - SHA-256 hash has the required leading zero bits - - Stamp has not been used before (replay prevention) + - Stamp has not been used before (replay prevention; the spent stamps are + kept in memory, so a restart forgets them) ### Stamp Format @@ -2861,8 +3128,8 @@ creating one session pays once and keeps their session. ### Why Hashcash and Not Rate Limits? -- **No state to track**: No IP tables, no token buckets, no sliding windows. The - server only needs to verify a single hash. +- **Little state to track**: No IP tables, no token buckets, no sliding windows. + The server verifies a single hash and remembers the stamps already spent. - **Works through NATs and proxies**: Doesn't punish shared IPs (university campuses, corporate networks, Tor exits). Every client computes their own proof independently. @@ -2910,11 +3177,11 @@ When the limit is exceeded, the server returns **429 Too Many Requests** with a > targeted attack. **Why rate limits here but not on session creation?** Session creation is -protected by hashcash proof-of-work (stateless, no IP tracking needed). Login -involves bcrypt password verification against a registered account — a -fundamentally different threat model where an attacker targets a specific -account. Per-IP rate limiting is appropriate here because the cost of a wrong -guess is borne by the server (bcrypt), not the client. +protected by hashcash proof-of-work (no IP tracking needed). Login involves +bcrypt password verification against a registered account — a fundamentally +different threat model where an attacker targets a specific account. Per-IP rate +limiting is appropriate here because the cost of a wrong guess is borne by the +server (bcrypt), not the client. --- @@ -2933,7 +3200,7 @@ guess is borne by the server (bcrypt), not the client. - [x] QUIT with broadcast and cleanup - [x] Embedded web SPA client - [x] CLI client (neoirc-cli) -- [x] SQLite storage with WAL mode +- [x] SQLite storage - [x] Docker deployment - [x] Prometheus metrics endpoint - [x] Health check endpoint @@ -2948,8 +3215,9 @@ guess is borne by the server (bcrypt), not the client. - [x] **Client output queue pruning** — delete old client output queue entries per `QUEUE_MAX_AGE` - [x] **Message rotation** — prune messages older than `MESSAGE_MAX_AGE` -- [x] **Channel modes** — enforce `+m` (moderated), `+t` (topic lock), `+n` (no - external) +- [x] **Channel modes** — enforce `+m` (moderated), `+t` (topic lock); only + members can send to a channel, so `+n` (no external) is shown but changes + nothing - [x] **Channel modes (tier 2)** — enforce `+i` (invite-only), `+s` (secret), `+b` (ban), `+k` (key), `+l` (limit) - [x] **User channel modes** — `+o` (operator), `+v` (voice) with NAMES prefixes @@ -2965,9 +3233,11 @@ guess is borne by the server (bcrypt), not the client. - [x] **KICK command** — remove users from channels (operator-only) - [x] **Numeric replies** — send IRC numeric codes via the message queue (001-005 welcome, 251-255 LUSERS, 311-319 WHOIS, 322-329 LIST/MODE, - 331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors) -- [ ] **Max message size enforcement** — reject oversized messages -- [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag) + 331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-502 errors) +- [x] **Max message size enforcement** — reject request bodies over + `MAX_MESSAGE_SIZE` bytes +- [x] **NOTICE command** — distinct from PRIVMSG over the HTTP API (no RPL_AWAY, + no hashcash check) - [x] **Multi-client sessions** — set a password via PASS command, then login from additional devices via `POST /api/v1/login` - [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for all API @@ -2994,8 +3264,8 @@ guess is borne by the server (bcrypt), not the client. - [x] **User info command** — WHOIS for querying user info and channels - [ ] **Connection flood protection** — per-IP connection limits as a complement to hashcash -- [ ] **Invite system** — `INVITE` command for `+i` channels -- [ ] **Ban system** — channel-level bans by nick pattern +- [x] **Invite system** — `INVITE` command for `+i` channels +- [x] **Ban system** — channel-level bans by hostmask pattern --- @@ -3025,17 +3295,26 @@ neoirc/ │ │ └── config.go │ ├── db/ # Database access and migrations │ │ ├── db.go # Connection, migration runner -│ │ ├── queries.go # All SQL queries and data types +│ │ ├── auth.go # Passwords and login +│ │ ├── errors.go # Error helpers +│ │ ├── queries.go # The other SQL queries and data types │ │ └── schema/ +│ │ ├── 000.sql # schema_migrations bootstrap │ │ └── 001_initial.sql │ ├── globals/ # Application-wide metadata │ │ └── globals.go │ ├── handlers/ # HTTP request handlers -│ │ ├── handlers.go # Deps, JSON response helper -│ │ ├── api.go # All API endpoint handlers +│ │ ├── handlers.go # Deps, JSON response helper, cleanup loop +│ │ ├── api.go # Most API endpoint handlers +│ │ ├── auth.go # Login and PASS +│ │ ├── utility.go # Operator commands, user MODE, tier 3 commands │ │ └── healthcheck.go # Health check handler +│ ├── hashcash/ # Hashcash stamp validation │ ├── healthcheck/ # Health check logic │ │ └── healthcheck.go +│ ├── ircserver/ # IRC protocol listener +│ ├── ratelimit/ # Per-IP login rate limiter +│ ├── service/ # Command logic shared by HTTP API and IRC listener │ ├── stats/ # Runtime statistics (atomic counters) │ │ └── stats.go │ ├── logger/ # slog-based logging @@ -3056,7 +3335,9 @@ neoirc/ │ │ ├── index.html │ │ └── style.css │ └── dist/ # Generated at Docker build time (not committed) -├── schema/ # JSON Schema definitions (planned) +├── pkg/ +│ └── irc/ # IRC command names and numeric reply codes +├── schema/ # JSON Schema definitions ├── script/ # Entrypoints that the Makefile targets call ├── go.mod ├── go.sum @@ -3072,7 +3353,7 @@ neoirc/ | Purpose | Library | | ---------- | ------------------------------------------------------- | | DI | `go.uber.org/fx` | -| Router | `github.com/go-chi/chi` | +| Router | `github.com/go-chi/chi/v5` | | Logging | `log/slog` (stdlib) | | Config | `github.com/spf13/viper` | | Env | `github.com/joho/godotenv/autoload` | @@ -3102,10 +3383,11 @@ neoirc/ 1459/2812. If you've built an IRC client or bot, you already know the command vocabulary. The only new things are the JSON encoding and the HTTP transport. -4. **HTTP is the only transport** — no WebSockets, no raw TCP, no protocol - negotiation. HTTP is universal, proxy-friendly, CDN-friendly, and works on - every device and network. Long-polling provides real-time delivery without - any of the complexity of persistent connections. +4. **HTTP is the only transport** — no WebSockets, no protocol negotiation, and + no raw TCP apart from the [IRC Protocol Listener](#irc-protocol-listener) + kept for standard IRC clients. HTTP is universal, proxy-friendly, + CDN-friendly, and works on every device and network. Long-polling provides + real-time delivery without any of the complexity of persistent connections. 5. **Server holds state** — clients are stateless. Reconnect, switch devices, lose connectivity for hours — your messages are waiting in your client queue. @@ -3148,7 +3430,7 @@ neoirc/ **Implementation in progress.** Core API is functional with: -- SQLite storage with WAL mode +- SQLite storage - All core IRC commands (PRIVMSG, JOIN, PART, NICK, TOPIC, QUIT, PING) - IRC message envelope format with per-client queue fan-out - Long-polling with in-memory broker -- 2.54.0 From a054bf828db4f06aa82f7f5f5b8be63110e412bd Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 8 Oct 2026 12:01:48 +0200 Subject: [PATCH 11/40] Give each IRC listener test its own database (closes #129) Each test environment in internal/ircserver named its shared in-memory database after the test. A repeat run of a test in the same process could open the database that an earlier run's server was still holding, and start on that run's users. The database now gets a random name, and a new test checks that two environments open at once do not share one. The database close and the server stop are now registered as soon as each is opened, so a setup that fails part-way still closes its database. Model: opus-5-5 --- internal/ircserver/server_test.go | 37 +++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index 99e860b..70e922b 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -2,6 +2,7 @@ package ircserver_test import ( "bufio" + "crypto/rand" "database/sql" "fmt" "log/slog" @@ -69,9 +70,11 @@ func newTestEnvWithConfig( ) *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( "file:%s?mode=memory&cache=shared&_journal_mode=WAL", - t.Name(), + rand.Text(), ) conn, err := sql.Open("sqlite", dsn) @@ -79,6 +82,13 @@ func newTestEnvWithConfig( t.Fatalf("open db: %v", err) } + t.Cleanup(func() { + err := conn.Close() + if err != nil { + t.Logf("close db: %v", err) + } + }) + conn.SetMaxOpenConns(1) _, err = conn.ExecContext( @@ -123,14 +133,9 @@ func newTestEnvWithConfig( t.Fatalf("start irc server: %v", err) } - t.Cleanup(func() { - srv.Stop() - - err := conn.Close() - if err != nil { - t.Logf("close db: %v", err) - } - }) + // Cleanups run last registered first, so the server stops + // before its database is closed. + t.Cleanup(srv.Stop) return &testEnv{ database: database, @@ -342,6 +347,20 @@ func TestRegistration(t *testing.T) { assertContains(t, lines, " 001 ", "RPL_WELCOME") } +// 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) { t.Parallel() -- 2.54.0 From 7c6f0bc01e99b1fe66bf85a72a5cef34cd11b415 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 20 Oct 2026 11:30:09 +0200 Subject: [PATCH 12/40] Run SQLite in WAL mode with a busy timeout the driver reads (closes #123) The default database URL asked for WAL mode with `_journal_mode` and `_busy_timeout`, which the SQLite driver does not read, so the database ran in rollback-journal mode. The default now uses the driver's `_pragma=journal_mode(WAL)` and `_pragma=busy_timeout(5000)` parameters, in one constant that both the config default and the database fallback use. The database no longer sets its own busy timeout after opening, so the URL's value applies. A new test opens a file database with those parameters through db.New and checks the journal mode and busy timeout. README.md's database text now matches, and an in-memory test database drops the same ignored parameter. Model: opus-5-5 Co-authored-by: clawbot --- README.md | 61 +++++++++++++------------ internal/config/config.go | 8 +++- internal/db/db.go | 9 +--- internal/db/db_test.go | 75 +++++++++++++++++++++++++++++++ internal/ircserver/server_test.go | 2 +- 5 files changed, 116 insertions(+), 39 deletions(-) create mode 100644 internal/db/db_test.go diff --git a/README.md b/README.md index 3fe6cf4..54a0f23 100644 --- a/README.md +++ b/README.md @@ -2581,28 +2581,28 @@ and Viper also reads a YAML file `neoirc.yaml` from `/etc/neoirc/` or environment variable wins over the file. An environment variable set to the empty string counts as unset, so the file's value or the default applies. -| Variable | Type | Default | Description | -| ---------------------- | ------ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PORT` | int | `8080` | HTTP listen port | -| `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. The SQLite driver ignores `_journal_mode`, so this does not turn on WAL mode. | -| `DEBUG` | bool | `false` | Switch logging to debug level | -| `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. | -| `SESSION_IDLE_TIMEOUT` | string | `720h` | Idle timeout as a Go duration string (e.g. `720h`, `24h`). Clients with no authenticated HTTP API request or IRC listener command for this long are removed, and a session left with none is expired and its nick released (see [Data Lifecycle](#data-lifecycle)). Default is 30 days. | -| `QUEUE_MAX_AGE` | string | `720h` | Maximum age of client output queue entries as a Go duration string (e.g. `720h`, `24h`). Entries older than this are pruned. Default is 30 days. | -| `MAX_MESSAGE_SIZE` | int | `4096` | Maximum request body size in bytes for `POST /api/v1/session`, `POST /api/v1/login` and `POST /api/v1/messages`; a larger body gets `400` `invalid request body` | -| `MOTD` | string | a built-in banner | Message of the day, sent to clients as MOTD numerics and shown via `GET /api/v1/server` | -| `SERVER_NAME` | string | `""` | Server display name. If empty, the server calls itself `neoirc` in replies and in `GET /api/v1/server` returns it empty. | -| `FEDERATION_KEY` | string | `""` | Shared key for server federation linking (planned) | -| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) | -| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. | -| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint | -| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. | -| `NEOIRC_OPER_NAME` | string | `""` | Server operator (o-line) username. Both name and password must be set to enable OPER. | -| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. | -| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. | -| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. | -| `IRC_LISTEN_ADDR` | string | `:6667` | TCP address for the traditional IRC protocol listener. Only an empty value in `neoirc.yaml` disables it; an empty environment variable does not. | -| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) | +| Variable | Type | Default | Description | +| ---------------------- | ------ | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PORT` | int | `8080` | HTTP listen port | +| `DBURL` | string | `file:///var/lib/neoirc/state.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)` | SQLite connection string. For file-based: `file:///path/to/db.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)`. For in-memory (testing): `file::memory:?cache=shared`. See [SQLite Considerations](#sqlite-considerations). | +| `DEBUG` | bool | `false` | Switch logging to debug level | +| `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. | +| `SESSION_IDLE_TIMEOUT` | string | `720h` | Idle timeout as a Go duration string (e.g. `720h`, `24h`). Clients with no authenticated HTTP API request or IRC listener command for this long are removed, and a session left with none is expired and its nick released (see [Data Lifecycle](#data-lifecycle)). Default is 30 days. | +| `QUEUE_MAX_AGE` | string | `720h` | Maximum age of client output queue entries as a Go duration string (e.g. `720h`, `24h`). Entries older than this are pruned. Default is 30 days. | +| `MAX_MESSAGE_SIZE` | int | `4096` | Maximum request body size in bytes for `POST /api/v1/session`, `POST /api/v1/login` and `POST /api/v1/messages`; a larger body gets `400` `invalid request body` | +| `MOTD` | string | a built-in banner | Message of the day, sent to clients as MOTD numerics and shown via `GET /api/v1/server` | +| `SERVER_NAME` | string | `""` | Server display name. If empty, the server calls itself `neoirc` in replies and in `GET /api/v1/server` returns it empty. | +| `FEDERATION_KEY` | string | `""` | Shared key for server federation linking (planned) | +| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) | +| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. | +| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint | +| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. | +| `NEOIRC_OPER_NAME` | string | `""` | Server operator (o-line) username. Both name and password must be set to enable OPER. | +| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. | +| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. | +| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. | +| `IRC_LISTEN_ADDR` | string | `:6667` | TCP address for the traditional IRC protocol listener. Only an empty value in `neoirc.yaml` disables it; an empty environment variable does not. | +| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) | ### Example `.env` file @@ -2611,7 +2611,7 @@ PORT=8080 SERVER_NAME=My NeoIRC Server MOTD=Welcome! Be excellent to each other. DEBUG=false -DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL +DBURL=file:///var/lib/neoirc/state.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000) SESSION_IDLE_TIMEOUT=720h NEOIRC_HASHCASH_BITS=20 ``` @@ -2821,14 +2821,17 @@ seconds to accommodate long-poll connections. ### SQLite Considerations -- **Journal mode**: the default connection string carries `?_journal_mode=WAL`, - but the SQLite driver ignores that parameter, so the database runs in SQLite's - default rollback-journal mode, not WAL. +- **Journal mode**: the default connection string runs the database in WAL mode + with a 5000 ms busy timeout, through the SQLite driver's + `_pragma=journal_mode(WAL)` and `_pragma=busy_timeout(5000)` parameters. A + `DBURL` of your own should keep both. - **Single writer**: the server uses one database connection, so reads and writes take turns. For high-traffic servers, Postgres support is planned. -- **Backup**: The database is a single file. Back it up with - `sqlite3 /var/lib/neoirc/state.db ".backup backup.db"`, or copy the file while - the server is stopped. +- **Backup**: Back the database up with + `sqlite3 /var/lib/neoirc/state.db ".backup backup.db"`, or copy `state.db` + while the server is stopped, together with `state.db-wal` if one is left + beside it: in WAL mode that file holds recent writes not yet folded into + `state.db`. - **Location**: By default, `state.db` is created in `/var/lib/neoirc/`. Use the `DBURL` env var to place it elsewhere. diff --git a/internal/config/config.go b/internal/config/config.go index db57556..a3db54d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,12 @@ const defaultMOTD = ` _ __ ___ ___ (_)_ __ ___ Welcome to NeoIRC — IRC semantics over HTTP. 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. type Params struct { fx.In @@ -71,7 +77,7 @@ func New( viper.SetDefault("DEBUG", "false") viper.SetDefault("MAINTENANCE_MODE", "false") viper.SetDefault("PORT", "8080") - viper.SetDefault("DBURL", "file:///var/lib/neoirc/state.db?_journal_mode=WAL") + viper.SetDefault("DBURL", DefaultDBURL) viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_PASSWORD", "") diff --git a/internal/db/db.go b/internal/db/db.go index 43fad60..e2dce3a 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -87,7 +87,7 @@ func (database *Database) GetDB() *sql.DB { func (database *Database) connect(ctx context.Context) error { dbURL := database.params.Config.DBURL if dbURL == "" { - dbURL = "file:///var/lib/neoirc/state.db?_journal_mode=WAL&_busy_timeout=5000" + dbURL = config.DefaultDBURL } database.log.Info( @@ -116,13 +116,6 @@ func (database *Database) connect(ctx context.Context) error { 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) } diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..c79fdfc --- /dev/null +++ b/internal/db/db_test.go @@ -0,0 +1,75 @@ +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, + ) + } +} diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index 70e922b..35ca0a6 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -73,7 +73,7 @@ func newTestEnvWithConfig( // A random name, so that no other test environment, not // even an earlier run of the same test, can share it. dsn := fmt.Sprintf( - "file:%s?mode=memory&cache=shared&_journal_mode=WAL", + "file:%s?mode=memory&cache=shared", rand.Text(), ) -- 2.54.0 From 1019419b171aad8d3b225f13f11846050ea9a04c Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 20 Oct 2026 11:58:39 +0200 Subject: [PATCH 13/40] Page history by message id (closes #125) GET /api/v1/history took a database id for before, but history responses carry only each message's id, so a client had nothing to pass for the next page. before now takes that id: the server finds the message among those sent to the target and returns the page older than it. An id with no such message gets 400 "unknown before id" instead of a silent full page. A handler test pages back through a channel using only ids from earlier responses, and README.md's history section describes the new parameter. Model: opus-5-5 Co-authored-by: clawbot --- README.md | 20 +++-- internal/db/queries.go | 23 ++++- internal/db/queries_test.go | 2 +- internal/handlers/api.go | 18 ++-- internal/handlers/api_test.go | 159 ++++++++++++++++++++++++++++++++++ 5 files changed, 205 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 54a0f23..11cca0e 100644 --- a/README.md +++ b/README.md @@ -1836,14 +1836,18 @@ Fetch historical messages for a channel. Returns messages in chronological order **Query Parameters:** -| Param | Type | Default | Description | -| -------- | ------- | ---------- | -------------------------------------------------------------------------------- | -| `target` | string | (required) | Channel name (e.g., `#general`) | -| `before` | integer | `0` | Return only messages with DB ID < this value (for pagination). `0` means latest. | -| `limit` | integer | `50` | Maximum messages to return, 1–500; any other value gives 50. | +| Param | Type | Default | Description | +| -------- | ------- | ---------- | ------------------------------------------------------------------------------------------------ | +| `target` | string | (required) | Channel name (e.g., `#general`) | +| `before` | string | (none) | The `id` of a message sent to `target`; return only messages older than it. Omit for the latest. | +| `limit` | integer | `50` | Maximum messages to return, 1–500; any other value gives 50. | + +To page back, pass the `id` of the first (oldest) message of a page as `before` +for the next; an empty page means there is nothing older. `target` may also be your own nick, which returns the DMs sent to you. Errors: -`400` `target required`, `404` `channel not found`, `403` +`400` `target required`, `400` `unknown before id` when no message with that +`id` was sent to `target`, `404` `channel not found`, `403` `not a member of this channel`, and `403` `forbidden` for any other nick. **Response:** `200 OK` @@ -1881,8 +1885,8 @@ events). Event messages are delivered via the live queue only. curl -s "http://localhost:8080/api/v1/history?target=%23general&limit=50" \ -b cookies.txt | jq . -# Older messages (pagination) -curl -s "http://localhost:8080/api/v1/history?target=%23general&before=100&limit=50" \ +# The 50 messages before uuid-1 (pagination) +curl -s "http://localhost:8080/api/v1/history?target=%23general&before=uuid-1&limit=50" \ -b cookies.txt | jq . ``` diff --git a/internal/db/queries.go b/internal/db/queries.go index 29239ea..b50a2eb 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -1058,17 +1058,34 @@ func (database *Database) PollMessages( return msgs, lastQID, nil } -// GetHistory returns message history for a target. +// GetHistory returns message history for a target. A +// non-empty before is the id of a message sent to target, +// and only messages older than it are returned; an id with +// no such message gives an error wrapping sql.ErrNoRows. func (database *Database) GetHistory( ctx context.Context, - target string, - beforeID int64, + target, before string, limit int, ) ([]IRCMessage, error) { if limit <= 0 { limit = defaultHistLimit } + var beforeID int64 + + if before != "" { + err := database.conn.QueryRowContext(ctx, + `SELECT id FROM messages + WHERE uuid = ? AND msg_to = ?`, + before, target, + ).Scan(&beforeID) + if err != nil { + return nil, fmt.Errorf( + "find message %q: %w", before, err, + ) + } + } + rows, err := database.queryHistory( ctx, target, beforeID, limit, ) diff --git a/internal/db/queries_test.go b/internal/db/queries_test.go index d5467a9..40e8f4d 100644 --- a/internal/db/queries_test.go +++ b/internal/db/queries_test.go @@ -721,7 +721,7 @@ func TestGetHistory(t *testing.T) { const histLimit = 5 msgs, err := database.GetHistory( - ctx, "#hist", 0, histLimit, + ctx, "#hist", "", histLimit, ) if err != nil { t.Fatal(err) diff --git a/internal/handlers/api.go b/internal/handlers/api.go index cde58bd..917eb36 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "database/sql" "encoding/json" "errors" "fmt" @@ -3558,10 +3559,6 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc { return } - beforeID, _ := strconv.ParseInt( - request.URL.Query().Get("before"), 10, 64, - ) - limit, _ := strconv.Atoi( request.URL.Query().Get("limit"), ) @@ -3570,8 +3567,19 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc { } msgs, err := hdlr.params.Database.GetHistory( - request.Context(), target, beforeID, limit, + request.Context(), target, + 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 { hdlr.log.Error( "get history failed", "error", err, diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 4fff74e..e74f9fd 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -14,6 +14,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "os" "slices" "strconv" @@ -1482,6 +1483,164 @@ func TestHistoryNonMember(t *testing.T) { } } +// getHistory fetches one page of target's history and +// returns the status and, on 200, the messages on it. +func (tserver *testServer) getHistory( + token, target, before string, + limit int, +) (int, []db.IRCMessage) { + tserver.t.Helper() + + query := url.Values{} + query.Set("target", target) + query.Set("before", before) + query.Set("limit", strconv.Itoa(limit)) + + resp, err := doRequestAuth( + tserver.t, http.MethodGet, + tserver.url("/api/v1/history?"+query.Encode()), + token, nil, + ) + if err != nil { + tserver.t.Fatalf("get history: %v", err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return resp.StatusCode, nil + } + + var msgs []db.IRCMessage + + decErr := json.NewDecoder(resp.Body).Decode(&msgs) + if decErr != nil { + tserver.t.Fatalf("decode history: %v", decErr) + } + + return resp.StatusCode, msgs +} + +func TestHistoryPaging(t *testing.T) { + const ( + channel = "#paging" + msgCount = 5 + pageSize = 2 + wantPages = 3 + ) + + tserver := newTestServer(t) + token := tserver.createSession("pager") + + tserver.sendCommand(token, map[string]any{ + commandKey: joinCmd, toKey: channel, + }) + + want := make([]string, 0, msgCount) + + for i := range msgCount { + text := fmt.Sprintf("message %d", i+1) + want = append(want, text) + + tserver.sendCommand(token, map[string]any{ + commandKey: privmsgCmd, + toKey: channel, + bodyKey: []string{text}, + }) + } + + var ( + got []string + pages int + ) + + // The first page has no before; each later one passes the + // id of the oldest message on the page fetched before it. + before := "" + + for range msgCount { + status, page := tserver.getHistory( + token, channel, before, pageSize, + ) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + if len(page) == 0 { + break + } + + pages++ + + texts := make([]string, 0, len(page)) + + for _, msg := range page { + var body []string + + err := json.Unmarshal(msg.Body, &body) + if err != nil { + t.Fatalf("decode body: %v", err) + } + + texts = append(texts, body...) + } + + got = slices.Insert(got, 0, texts...) + before = page[0].ID + } + + if pages != wantPages { + t.Fatalf( + "expected %d pages, got %d", wantPages, pages, + ) + } + + if !slices.Equal(got, want) { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestHistoryUnknownBefore(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("lost") + + for _, channel := range []string{"#here", "#elsewhere"} { + tserver.sendCommand(token, map[string]any{ + commandKey: joinCmd, toKey: channel, + }) + tserver.sendCommand(token, map[string]any{ + commandKey: privmsgCmd, + toKey: channel, + bodyKey: []string{"hi there"}, + }) + } + + _, elsewhere := tserver.getHistory( + token, "#elsewhere", "", 1, + ) + if len(elsewhere) != 1 { + t.Fatalf( + "expected 1 message, got %d", len(elsewhere), + ) + } + + cases := []struct{ name, before string }{ + {"made-up id", "no-such-message"}, + {"id from another channel", elsewhere[0].ID}, + } + + for _, tc := range cases { + status, _ := tserver.getHistory( + token, "#here", tc.before, 1, + ) + if status != http.StatusBadRequest { + t.Fatalf( + "%s: expected 400, got %d", tc.name, status, + ) + } + } +} + func TestChannelList(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession("lister") -- 2.54.0 From 5b430cf8354bc83290a016c3071a0d4bf2f3e52a Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 20 Oct 2026 12:35:18 +0200 Subject: [PATCH 14/40] Let an empty IRC_LISTEN_ADDR disable the IRC listener (closes #124) Viper treats an environment variable set to the empty string as unset, so an empty IRC_LISTEN_ADDR fell back to the :6667 default and the IRC listener could not be disabled from the environment. The config now reads IRC_LISTEN_ADDR with os.LookupEnv as well, so an empty value disables the listener and an unset one keeps :6667. Viper's own switch for this applies to every setting, so it is not used. New config tests cover both cases, and README.md says how to disable the listener. Model: opus-5-5 Co-authored-by: clawbot --- README.md | 17 +++++++--- internal/config/config.go | 8 +++++ internal/config/config_test.go | 61 ++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 internal/config/config_test.go diff --git a/README.md b/README.md index 11cca0e..d81f1c7 100644 --- a/README.md +++ b/README.md @@ -2583,7 +2583,9 @@ is also loaded automatically via [godotenv](https://github.com/joho/godotenv), and Viper also reads a YAML file `neoirc.yaml` from `/etc/neoirc/` or `$HOME/.config/neoirc/` if one exists, with the same names as keys; an environment variable wins over the file. An environment variable set to the -empty string counts as unset, so the file's value or the default applies. +empty string counts as unset, so the file's value or the default applies; the +one exception is `IRC_LISTEN_ADDR`, where the empty string disables the IRC +listener. | Variable | Type | Default | Description | | ---------------------- | ------ | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -2605,7 +2607,7 @@ empty string counts as unset, so the file's value or the default applies. | `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. | | `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. | | `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. | -| `IRC_LISTEN_ADDR` | string | `:6667` | TCP address for the traditional IRC protocol listener. Only an empty value in `neoirc.yaml` disables it; an empty environment variable does not. | +| `IRC_LISTEN_ADDR` | string | `:6667` | TCP address for the traditional IRC protocol listener. Set it to the empty string, in the environment or in `neoirc.yaml`, to disable the listener. | | `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) | ### Example `.env` file @@ -2632,9 +2634,14 @@ and others. ### Configuration The IRC listener is **enabled by default** on `:6667`. To disable it, set -`IRC_LISTEN_ADDR` to an empty string in `neoirc.yaml` (see -[Configuration](#configuration)); an empty environment variable counts as unset -and leaves it on `:6667`: +`IRC_LISTEN_ADDR` to an empty string (see [Configuration](#configuration)), +either in the environment: + +```bash +IRC_LISTEN_ADDR= +``` + +or in `neoirc.yaml`: ```yaml IRC_LISTEN_ADDR: "" diff --git a/internal/config/config.go b/internal/config/config.go index a3db54d..23f85b6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ package config import ( "errors" "log/slog" + "os" "github.com/spf13/viper" "go.uber.org/fx" @@ -129,6 +130,13 @@ func New( params: ¶ms, } + // 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 { params.Logger.EnableDebugLogging() cfg.log = params.Logger.Get() diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..fc8163a --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,61 @@ +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) + } +} -- 2.54.0 From 4ab320ab2d3f8828f577185e93da40360f872c6a Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 20 Oct 2026 13:04:40 +0200 Subject: [PATCH 15/40] Give each handler and service test its own database (closes #131) Test databases in internal/handlers and internal/service were named after the address of the running test. A later test could get the address of a finished one, and with it a database that test's server might still hold open. Each test database now gets a random name, as in internal/ircserver. A new test in each package opens two test databases at once and checks that they do not share one; both new tests fail with the old names. Model: opus-5-5 Co-authored-by: clawbot --- internal/handlers/api_test.go | 31 +++++++++++++++++++++++++++++-- internal/service/service_test.go | 21 +++++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index e74f9fd..d859b55 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -7,6 +7,7 @@ package handlers_test import ( "bytes" "context" + "crypto/rand" "database/sql" "encoding/json" "errors" @@ -152,9 +153,12 @@ func newTestServerWith( ) *testServer { t.Helper() + // A random name, so that no other test can share it, not + // even one that has finished but still holds its database + // open. dbURL := fmt.Sprintf( - "file:test_%p?mode=memory&cache=shared", - t, + "file:%s?mode=memory&cache=shared", + rand.Text(), ) copyTemplateDB(t, dbURL) @@ -667,6 +671,29 @@ func TestCreateSessionDuplicate(t *testing.T) { } } +// TestEachTestServerHasItsOwnDatabase checks that starting a +// second test server leaves the first one's database alone and +// starts on an empty one of its own. +func TestEachTestServerHasItsOwnDatabase(t *testing.T) { + first := newTestServer(t) + first.createSession("samenick") + + second := newTestServer(t) + + resp := postSession(t, first, "samenick") + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusConflict { + t.Fatalf( + "first server: expected 409, got %d", + resp.StatusCode, + ) + } + + second.createSession("samenick") +} + func TestCreateSessionEmpty(t *testing.T) { tserver := newTestServer(t) diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 2cc9ef2..e619054 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -6,6 +6,7 @@ package service_test import ( "context" + "crypto/rand" "encoding/json" "errors" "fmt" @@ -43,9 +44,12 @@ type testEnv struct { func newTestEnv(t *testing.T) *testEnv { t.Helper() + // A random name, so that no other test can share it, not + // even one that has finished but still holds its database + // open. dbURL := fmt.Sprintf( - "file:svc_test_%p?mode=memory&cache=shared", - t, + "file:%s?mode=memory&cache=shared", + rand.Text(), ) var ( @@ -137,6 +141,19 @@ func createSession( return sessionID } +// TestEachTestEnvHasItsOwnDatabase checks that a test +// environment starts on an empty database while another one +// is still open. +func TestEachTestEnvHasItsOwnDatabase(t *testing.T) { + ctx := t.Context() + + first := newTestEnv(t) + createSession(ctx, t, first.db, "samenick") + + second := newTestEnv(t) + createSession(ctx, t, second.db, "samenick") +} + func TestFanOut(t *testing.T) { env := newTestEnv(t) ctx := t.Context() -- 2.54.0 From 1308769a2582303760194335b996395451558f3c Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 20 Oct 2026 13:48:16 +0200 Subject: [PATCH 16/40] Fix IRC listener relay of bodies and modes, and JOIN names (closes #127) The IRC listener relayed only the first line of a PRIVMSG or NOTICE body; it now sends one line per body line. A relayed MODE change now carries its key, limit or mask, each value its own parameter, also in the echo to the user who made it. CR, LF and NUL are removed from each parameter and from each whole line sent to an IRC listener connection, so no stored value can start a line of its own. The channel name check moved into service.JoinChannel, so the IRC listener's JOIN refuses the same names with 403. Model: opus-5-5 Co-authored-by: clawbot --- README.md | 29 ++--- internal/handlers/api.go | 14 --- internal/ircserver/commands.go | 9 +- internal/ircserver/conn.go | 8 +- internal/ircserver/conn_test.go | 46 ++++++++ internal/ircserver/parser.go | 9 +- internal/ircserver/relay.go | 40 +++++-- internal/ircserver/relay_test.go | 187 ++++++++++++++++++++++++++++++ internal/ircserver/server_test.go | 31 +++++ internal/service/service.go | 18 ++- 10 files changed, 345 insertions(+), 46 deletions(-) create mode 100644 internal/ircserver/relay_test.go diff --git a/README.md b/README.md index d81f1c7..1d68680 100644 --- a/README.md +++ b/README.md @@ -2667,7 +2667,9 @@ IRC_LISTEN_ADDR: "" - **CAP negotiation**: `CAP LS` gets an empty capability list and `CAP END` is ignored, for compatibility with modern clients. - **Channel prefixes**: Channels must start with `#`. `JOIN` prepends it if - omitted; other commands use the name as given. + omitted; other commands use the name as given. As over the HTTP API, `JOIN` + answers a name that is not `#` followed by 1–63 letters, digits, `_` or `-` + with ERR_NOSUCHCHANNEL (403). - **First joiner**: The first user to join a channel is automatically granted operator status (`@`). - **Channel modes**: `+i` (invite-only), `+m` (moderated), `+n` (no external), @@ -2689,21 +2691,20 @@ IRC_LISTEN_ADDR: "" Messages sent by IRC clients appear in channels visible to HTTP/JSON API clients and vice versa. The IRC listener and HTTP API share the same database, broker, and session infrastructure, so a user connected via IRC and a user connected via -the HTTP API can talk in the same channels, with these gaps today: +the HTTP API can talk in the same channels. -- An IRC listener user receives only the first line of a multi-line `body`. -- A `+k`, `+l` or `+b` change made over the HTTP API reaches IRC listener - members without its key, limit or mask. -- The IRC listener's `JOIN` accepts any channel name, while over the HTTP API a - name that is not `#` followed by 1–63 letters, digits, `_` or `-` gets - ERR_NOSUCHCHANNEL (403), so HTTP API users cannot join such a channel. -- The IRC listener does not pass on a `PRIVMSG`, `NOTICE`, `JOIN`, `PART`, - `NICK` or `QUIT` whose sender's nick matches the user's own nick ignoring - case, so an IRC listener user `alice` never sees these from `Alice`, although - nicks are case-sensitive. +Each line of a multi-line `body` reaches an IRC listener user as its own +`PRIVMSG` or `NOTICE` line. A CR, LF or NUL inside a line is removed before it +is sent, so it cannot start a line of its own. A mode change made over the HTTP +API reaches IRC listener members as an IRC `MODE` line with its value as a +separate parameter, e.g. `MODE #general +k secretpass` or +`MODE #general +o bob`. -https://git.eeqj.de/sneak/neoirc/issues/127 tracks the first three and -https://git.eeqj.de/sneak/neoirc/issues/128 the last. +One gap remains: the IRC listener does not pass on a `PRIVMSG`, `NOTICE`, +`JOIN`, `PART`, `NICK` or `QUIT` whose sender's nick matches the user's own nick +ignoring case, so an IRC listener user `alice` never sees these from `Alice`, +although nicks are case-sensitive. https://git.eeqj.de/sneak/neoirc/issues/128 +tracks it. ### Docker Usage diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 917eb36..76da66f 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -29,10 +29,6 @@ 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( `^[a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{1,32}$`, ) @@ -1489,16 +1485,6 @@ func (hdlr *Handlers) handleJoin( 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). var suppliedKey string diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index b5b0d75..40d83a9 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "slices" "strconv" "strings" "time" @@ -683,9 +684,11 @@ func (c *Conn) applyChannelModes( if applied.Len() > 0 { modeReply := applied.String() + appliedArgs.String() - c.send(FormatMessage( - c.hostmask(), "MODE", channel, modeReply, - )) + params := slices.Concat( + []string{channel}, strings.Fields(modeReply), + ) + + c.send(FormatMessage(c.hostmask(), "MODE", params...)) c.svc.BroadcastMode( ctx, c.nick, channel, chID, modeReply, diff --git a/internal/ircserver/conn.go b/internal/ircserver/conn.go index f8024f0..bd2adcf 100644 --- a/internal/ircserver/conn.go +++ b/internal/ircserver/conn.go @@ -297,11 +297,17 @@ func (c *Conn) send(line string) { } // sendWithin writes a formatted IRC line to the connection, -// giving up after timeout. +// giving up after timeout. CR, LF and NUL are removed first, +// as FormatMessage does for each parameter, so that a line +// built without it cannot end early either. func (c *Conn) sendWithin( timeout time.Duration, line string, ) { + line = strings.ReplaceAll(line, "\r", "") + line = strings.ReplaceAll(line, "\n", "") + line = strings.ReplaceAll(line, "\x00", "") + c.writeMu.Lock() defer c.writeMu.Unlock() diff --git a/internal/ircserver/conn_test.go b/internal/ircserver/conn_test.go index d860d81..0598a63 100644 --- a/internal/ircserver/conn_test.go +++ b/internal/ircserver/conn_test.go @@ -5,6 +5,7 @@ import ( "log/slog" "net" "os" + "slices" "strings" "testing" "time" @@ -100,3 +101,48 @@ func TestDisconnectNotifiesAndClosesVictim(t *testing.T) { 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) + } +} diff --git a/internal/ircserver/parser.go b/internal/ircserver/parser.go index 5374bc0..17e4546 100644 --- a/internal/ircserver/parser.go +++ b/internal/ircserver/parser.go @@ -90,7 +90,10 @@ func ParseMessage(line string) *Message { } // FormatMessage formats an IRC message into wire protocol -// format (without the trailing CR-LF). +// format (without the trailing CR-LF). CR, LF and NUL are +// 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( prefix, command string, params ...string, @@ -106,6 +109,10 @@ func FormatMessage( buf.WriteString(command) for i, param := range params { + param = strings.ReplaceAll(param, "\r", "") + param = strings.ReplaceAll(param, "\n", "") + param = strings.ReplaceAll(param, "\x00", "") + buf.WriteByte(' ') isLast := i == len(params)-1 diff --git a/internal/ircserver/relay.go b/internal/ircserver/relay.go index 60ccb74..844e705 100644 --- a/internal/ircserver/relay.go +++ b/internal/ircserver/relay.go @@ -3,6 +3,7 @@ package ircserver import ( "context" "encoding/json" + "slices" "strings" "time" @@ -105,7 +106,7 @@ func (c *Conn) deliverIRCMessage( case isNumeric(command): c.deliverNumeric(msg, text) case command == irc.CmdPrivmsg || command == irc.CmdNotice: - c.deliverTextMessage(msg, command, text) + c.deliverTextMessage(msg, command, bodyLines) case command == irc.CmdJoin: c.deliverJoin(msg) case command == irc.CmdPart: @@ -123,7 +124,7 @@ func (c *Conn) deliverIRCMessage( case command == irc.CmdWallops: c.deliverWallops(msg, text) case command == irc.CmdMode: - c.deliverMode(msg, text) + c.deliverMode(msg, bodyLines) case command == irc.CmdPing: // Server-originated PING — reply with PONG. c.sendFromServer("PING", c.serverSfx) @@ -179,10 +180,12 @@ func (c *Conn) deliverNumeric( c.send(FormatMessage(from, msg.Command, allParams...)) } -// deliverTextMessage sends PRIVMSG or NOTICE. +// deliverTextMessage sends PRIVMSG or NOTICE, one line for +// each line of the body. func (c *Conn) deliverTextMessage( msg *db.IRCMessage, - command, text string, + command string, + bodyLines []string, ) { from := msg.From target := msg.To @@ -201,7 +204,9 @@ func (c *Conn) deliverTextMessage( prefix = from + "!" + from + "@*" } - c.send(FormatMessage(prefix, command, target, text)) + for _, line := range bodyLines { + c.send(FormatMessage(prefix, command, target, line)) + } } // deliverJoin sends a JOIN notification. @@ -351,15 +356,26 @@ func (c *Conn) deliverWallops( )) } -// deliverMode sends a MODE change notification. +// deliverMode sends a MODE change notification. The first +// body line is the change, possibly followed by its values +// separated by spaces ("+o bob"); any further lines are +// values ("+k", "secret"). Each goes out as its own +// parameter, as IRC sends them. func (c *Conn) deliverMode( msg *db.IRCMessage, - text string, + bodyLines []string, ) { - prefix := msg.From + "!" + msg.From + "@*" - target := msg.To - - if text != "" { - c.send(FormatMessage(prefix, "MODE", target, text)) + if len(bodyLines) == 0 || bodyLines[0] == "" { + return } + + prefix := msg.From + "!" + msg.From + "@*" + + params := slices.Concat( + []string{msg.To}, + strings.Fields(bodyLines[0]), + bodyLines[1:], + ) + + c.send(FormatMessage(prefix, "MODE", params...)) } diff --git a/internal/ircserver/relay_test.go b/internal/ircserver/relay_test.go new file mode 100644 index 0000000..579d422 --- /dev/null +++ b/internal/ircserver/relay_test.go @@ -0,0 +1,187 @@ +package ircserver_test + +import ( + "encoding/json" + "slices" + "strings" + "testing" +) + +// queueMessage queues a message for the user with nick +// recipient the way the HTTP API sends one: stored once, then +// queued to the recipient's session. +func (env *testEnv) queueMessage( + t *testing.T, + recipient, command, from, to string, + body []string, +) { + t.Helper() + + ctx := t.Context() + + bodyJSON, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + + msgID, _, err := env.database.InsertMessage( + ctx, command, from, to, nil, bodyJSON, nil, + ) + if err != nil { + t.Fatalf("insert message: %v", err) + } + + sessionID, err := env.database.GetSessionByNick(ctx, recipient) + if err != nil { + t.Fatalf("get session of %s: %v", recipient, err) + } + + err = env.database.EnqueueToSession(ctx, sessionID, msgID) + if err != nil { + t.Fatalf("enqueue: %v", err) + } + + env.brk.Notify(sessionID) +} + +// TestRelayMultiLineBody checks that each line of a PRIVMSG +// or NOTICE body sent over the HTTP API reaches an IRC +// listener user as its own line. +func TestRelayMultiLineBody(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + alice := env.dial(t) + alice.register("alice") + + for _, command := range []string{"PRIVMSG", "NOTICE"} { + env.queueMessage( + t, "alice", command, "carol", "alice", + []string{"first line", "second line"}, + ) + + lines := alice.readUntil(func(l string) bool { + return strings.Contains(l, "second line") + }) + + want := []string{ + ":carol!carol@* " + command + " alice :first line", + ":carol!carol@* " + command + " alice :second line", + } + + got := lines[max(0, len(lines)-len(want)):] + if !slices.Equal(got, want) { + t.Errorf("%s: got %q, want %q", command, got, want) + } + } +} + +// TestRelayModeParameters checks that a MODE change reaches +// IRC listener members with its values as separate +// parameters, both for the bodies the HTTP API stores and +// for a change made on the IRC listener. +func TestRelayModeParameters(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + alice := env.dial(t) + alice.register("alice") + + for _, step := range []struct { + body []string + want string + }{ + {[]string{"+k", "secret"}, "+k secret"}, + {[]string{"-k", "*"}, "-k *"}, + {[]string{"+l", "50"}, "+l 50"}, + {[]string{"-l"}, "-l"}, + {[]string{"+b", "*!*@*.example.com"}, "+b *!*@*.example.com"}, + {[]string{"-b", "*!*@*.example.com"}, "-b *!*@*.example.com"}, + {[]string{"+o bob"}, "+o bob"}, + {[]string{"+m"}, "+m"}, + } { + env.queueMessage(t, "alice", "MODE", "op", "#modes", step.body) + + lines := alice.readUntil(func(l string) bool { + return strings.Contains(l, " MODE ") + }) + + got := lines[len(lines)-1] + want := ":op!op@* MODE #modes " + step.want + + if got != want { + t.Errorf("body %q: got %q, want %q", step.body, got, want) + } + } + + bob := env.dial(t) + bob.register("bob") + + alice.joinAndDrain("#modes") + bob.joinAndDrain("#modes") + + alice.send("MODE #modes +v bob") + + const change = " MODE #modes +v bob" + + for name, client := range map[string]*testClient{ + "alice": alice, "bob": bob, + } { + lines := client.readUntil(func(l string) bool { + return strings.Contains(l, " MODE ") + }) + + got := lines[len(lines)-1] + if !strings.HasSuffix(got, change) { + t.Errorf("%s: got %q, want it to end in %q", name, got, change) + } + } +} + +// 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) + } +} diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index 35ca0a6..e77a996 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -438,6 +438,37 @@ func TestJoinChannel(t *testing.T) { 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) { t.Parallel() diff --git a/internal/service/service.go b/internal/service/service.go index 361bcd3..d6edb06 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "log/slog" + "regexp" "strconv" "strings" "sync" @@ -35,6 +36,12 @@ const ( // for (RFC 2812). const maxUserhostNicks = 5 +// validChannelRe matches the channel names JOIN accepts: # +// followed by 1 to 63 letters, digits, _ or -. +var validChannelRe = regexp.MustCompile( + `^#[a-zA-Z0-9_\-]{1,63}$`, +) + // Params defines the dependencies for creating a Service. type Params struct { fx.In @@ -473,12 +480,21 @@ func (s *Service) SendDirectMessage( // JoinChannel creates or joins a channel, making the // first joiner the operator. Fans out the JOIN to all -// channel members. +// channel members. A channel name that validChannelRe +// does not match gets ERR_NOSUCHCHANNEL. func (s *Service) JoinChannel( ctx context.Context, sessionID int64, nick, channel, suppliedKey string, ) (*JoinResult, error) { + if !validChannelRe.MatchString(channel) { + return nil, &IRCError{ + irc.ErrNoSuchChannel, + []string{channel}, + msgNoSuchChannel, + } + } + chID, err := s.db.GetOrCreateChannel(ctx, channel) if err != nil { return nil, fmt.Errorf("get/create channel: %w", err) -- 2.54.0 From 3c725a9ca8261f34634ee41a37dfcc099405e348 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 20 Oct 2026 16:02:14 +0200 Subject: [PATCH 17/40] Enforce channel keys, invites, +s and +H on the IRC listener (closes #122) The IRC listener's JOIN now passes the channel key on, INVITE records the invite, LIST and WHOIS hide +s channels from non-members with the queries the HTTP API uses, and PRIVMSG to a +H channel gets 404. The INVITE checks and the channel hashcash check move from the HTTP handlers into the service, so both transports run the same code. An IRC client cannot attach a stamp, so its PRIVMSG to a +H channel always fails that check. The unfiltered channel list query, now unused, is removed. Model: opus-5-5 Co-authored-by: clawbot --- README.md | 55 ++--- internal/db/queries.go | 51 ----- internal/handlers/api.go | 297 +------------------------ internal/handlers/handlers.go | 36 ++- internal/ircserver/commands.go | 67 +++--- internal/ircserver/integration_test.go | 210 +++++++++++++++++ internal/service/service.go | 228 +++++++++++++++++-- 7 files changed, 494 insertions(+), 450 deletions(-) diff --git a/README.md b/README.md index 1d68680..a754b7c 100644 --- a/README.md +++ b/README.md @@ -1277,17 +1277,17 @@ Inspired by IRC, simplified. See [MODE](#mode--query-and-change-modes) for how to set them; `b`, `k` and `l` can be set only over the HTTP API, and `n` only on the IRC listener. -| Mode | Name | Meaning | Status | -| ---- | ----------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `+b` | Ban | Prevents matching hostmasks from joining or sending (parameter: `nick!user@host` mask with wildcards) | **Enforced** | -| `+i` | Invite-only | Only invited users can join; use `INVITE` to invite | **Enforced**; only an invite made over the HTTP API lets a user in | -| `+k` | Channel key | Requires a password to join (parameter: key string) | **Enforced**; the IRC listener sends no key, so its users cannot join a channel that has one | -| `+l` | User limit | Maximum number of members allowed in the channel (parameter: integer) | **Enforced** | -| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** | -| `+n` | No external | Only channel members can send messages to the channel (default: ON) | Shown only: non-members can never send to a channel, whether or not `+n` is set | -| `+s` | Secret | Channel hidden from LIST and WHOIS for non-members | **Enforced** by `LIST` and `WHOIS` over the HTTP API; not by `GET /api/v1/channels` or the IRC listener | -| `+t` | Topic lock | Only operators can change the topic (default: ON) | **Enforced** | -| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | **Enforced** over the HTTP API only | +| Mode | Name | Meaning | Status | +| ---- | ----------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `+b` | Ban | Prevents matching hostmasks from joining or sending (parameter: `nick!user@host` mask with wildcards) | **Enforced** | +| `+i` | Invite-only | Only invited users can join; use `INVITE` to invite | **Enforced** | +| `+k` | Channel key | Requires a password to join (parameter: key string) | **Enforced** | +| `+l` | User limit | Maximum number of members allowed in the channel (parameter: integer) | **Enforced** | +| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** | +| `+n` | No external | Only channel members can send messages to the channel (default: ON) | Shown only: non-members can never send to a channel, whether or not `+n` is set | +| `+s` | Secret | Channel hidden from LIST and WHOIS for non-members | **Enforced** by `LIST` and `WHOIS`; not by `GET /api/v1/channels` | +| `+t` | Topic lock | Only operators can change the topic (default: ON) | **Enforced** | +| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | **Enforced**; the IRC listener refuses every PRIVMSG to the channel, as it cannot carry a stamp | **User channel modes (set per-user per-channel):** @@ -1312,10 +1312,9 @@ bans. Bans prevent both joining and sending messages. These ban all users from example.com, remove that ban, and list all bans (RPL_BANLIST 367, RPL_ENDOFBANLIST 368). -**Invite-only (+i):** When set, users must be invited before joining. Over the -HTTP API, `INVITE` records an invite that is consumed on JOIN; any member can -invite, and on a `+i` channel only an operator can. `INVITE` on the IRC listener -tells the user about the invite but records none. +**Invite-only (+i):** When set, users must be invited before joining. `INVITE` +records an invite that is consumed on JOIN; any member can invite, and on a `+i` +channel only an operator can. ```json {"command": "MODE", "to": "#channel", "body": ["+i"]} @@ -1323,7 +1322,9 @@ tells the user about the invite but records none. ``` **Channel key (+k):** Requires a password to join the channel. Over the HTTP API -the key is `body[0]` of the `JOIN`. +the key is `body[0]` of the `JOIN`. On the IRC listener it follows the channel +name, as in `JOIN #channel secretpass`; `JOIN #a,#b keya,keyb` gives each +channel its own key. ```json {"command": "MODE", "to": "#channel", "body": ["+k", "secretpass"]} @@ -1338,9 +1339,8 @@ the key is `body[0]` of the `JOIN`. {"command": "MODE", "to": "#channel", "body": ["-l"]} ``` -**Secret (+s):** The HTTP API's `LIST` hides the channel from non-members, and -its `WHOIS` leaves it out of the channel list when the querier is not in the -same channel. +**Secret (+s):** `LIST` hides the channel from non-members, and `WHOIS` leaves +it out of the channel list when the querier is not in the same channel. **Join errors:** a user who is banned gets ERR_BANNEDFROMCHAN (474), one not invited to a `+i` channel ERR_INVITEONLYCHAN (473), one with a missing or wrong @@ -1351,10 +1351,10 @@ key ERR_BADCHANNELKEY (475), and one joining a full channel ERR_CHANNELISFULL `KICK #channel nick [:reason]`. The kicked user and all channel members receive the KICK message. -**NOTICE:** Over the HTTP API, a NOTICE gets no RPL_AWAY and no hashcash check -on `+H` channels, but it gets the same error numerics as PRIVMSG (411, 412, 401, -403, 404). The IRC listener handles NOTICE exactly as PRIVMSG, so a NOTICE to a -user who is away gets RPL_AWAY there. +**NOTICE:** A NOTICE gets no hashcash check on `+H` channels, but it gets the +same error numerics as PRIVMSG (411, 412, 401, 403, 404). Over the HTTP API it +gets no RPL_AWAY; the IRC listener sends RPL_AWAY for a NOTICE to a user who is +away, as it does for PRIVMSG. **ISUPPORT:** The server advertises `PREFIX=(ov)@+` in RPL_ISUPPORT (005), with `CHANMODES=b,k,Hl,imnst` over the HTTP API and `CHANMODES=,,H,imnst` on the IRC @@ -1362,10 +1362,11 @@ listener. ### Per-Channel Hashcash (Anti-Spam) -Channels can require hashcash proof-of-work for every `PRIVMSG` sent over the -HTTP API; the IRC listener does not check it. This is an anti-spam mechanism: -channel operators set a difficulty level, and clients must compute a -proof-of-work stamp bound to the specific channel and message before sending. +Channels can require hashcash proof-of-work for every `PRIVMSG`. This is an +anti-spam mechanism: channel operators set a difficulty level, and clients must +compute a proof-of-work stamp bound to the specific channel and message before +sending. An IRC listener client has no way to attach a stamp, so its `PRIVMSG` +to such a channel always gets ERR_CANNOTSENDTOCHAN (404). **Setting the requirement:** diff --git a/internal/db/queries.go b/internal/db/queries.go index b50a2eb..a552d95 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -1482,57 +1482,6 @@ type ChannelInfoFull struct { MemberCount int64 `json:"memberCount"` } -// ListAllChannelsWithCounts returns every channel -// with its member count. -func (database *Database) ListAllChannelsWithCounts( - ctx context.Context, -) ([]ChannelInfoFull, error) { - rows, err := database.conn.QueryContext(ctx, - `SELECT c.id, c.name, c.topic, - COUNT(cm.session_id) AS member_count - FROM channels c - LEFT JOIN channel_members cm - ON cm.channel_id = c.id - GROUP BY c.id - ORDER BY c.name`) - if err != nil { - return nil, fmt.Errorf( - "list channels with counts: %w", err, - ) - } - - defer func() { _ = rows.Close() }() - - var out []ChannelInfoFull - - for rows.Next() { - var chanInfo ChannelInfoFull - - err = rows.Scan( - &chanInfo.ID, &chanInfo.Name, - &chanInfo.Topic, &chanInfo.MemberCount, - ) - if err != nil { - return nil, fmt.Errorf( - "scan channel full: %w", err, - ) - } - - out = append(out, chanInfo) - } - - err = rows.Err() - if err != nil { - return nil, fmt.Errorf("rows error: %w", err) - } - - if out == nil { - out = []ChannelInfoFull{} - } - - return out, nil -} - // GetChannelCreatedAt returns the creation time of a // channel. func (database *Database) GetChannelCreatedAt( diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 76da66f..8ca7480 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -15,16 +15,10 @@ import ( "github.com/go-chi/chi/v5" "sneak.berlin/go/neoirc/internal/db" - "sneak.berlin/go/neoirc/internal/hashcash" "sneak.berlin/go/neoirc/internal/service" "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}$`, ) @@ -1231,23 +1225,6 @@ func (hdlr *Handlers) handleChannelMsg( ) { ctx := request.Context() - // Hashcash validation is HTTP-specific; needs chID. - isNotice := command == irc.CmdNotice - - if !isNotice { - 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, @@ -1272,160 +1249,6 @@ func (hdlr *Handlers) handleChannelMsg( 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( writer http.ResponseWriter, request *http.Request, @@ -2914,123 +2737,15 @@ func (hdlr *Handlers) handleInvite( 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() - chID, err := hdlr.params.Database.GetChannelByName( - ctx, channel, + targetSID, err := hdlr.svc.InviteUser( + ctx, sessionID, nick, targetNick, channel, ) - if err != nil { - hdlr.respondIRCError( - 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, - ) - + if hdlr.handleServiceError( + writer, request, + clientID, sessionID, nick, err, + ) { return } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index a150cc8..690b045 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -55,16 +55,15 @@ const spentHashcashTTL = 365 * 24 * time.Hour // Handlers manages HTTP request handling. type Handlers struct { - params *Params - log *slog.Logger - hc *healthcheck.Healthcheck - broker *broker.Broker - svc *service.Service - hashcashVal *hashcash.Validator - channelHashcash *hashcash.ChannelValidator - loginLimiter *ratelimit.Limiter - stats *stats.Tracker - cancelCleanup context.CancelFunc + params *Params + log *slog.Logger + hc *healthcheck.Healthcheck + broker *broker.Broker + svc *service.Service + hashcashVal *hashcash.Validator + loginLimiter *ratelimit.Limiter + stats *stats.Tracker + cancelCleanup context.CancelFunc } // New creates a new Handlers instance. @@ -88,15 +87,14 @@ func New( } hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup - params: ¶ms, - log: params.Logger.Get(), - hc: params.Healthcheck, - broker: params.Broker, - svc: params.Service, - hashcashVal: hashcash.NewValidator(resource), - channelHashcash: hashcash.NewChannelValidator(), - loginLimiter: ratelimit.New(loginRate, loginBurst), - stats: params.Stats, + params: ¶ms, + log: params.Logger.Get(), + hc: params.Healthcheck, + broker: params.Broker, + svc: params.Service, + hashcashVal: hashcash.NewValidator(resource), + loginLimiter: ratelimit.New(loginRate, loginBurst), + stats: params.Stats, } lifecycle.Append(fx.Hook{ diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index 40d83a9..fb2e8c5 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -150,7 +150,9 @@ func (c *Conn) handlePrivmsg( } // handleJoin joins one or more channels via the shared -// service layer. +// service layer. The optional second parameter holds the +// channel keys, comma-separated in the order of the +// channels. func (c *Conn) handleJoin( ctx context.Context, msg *Message, @@ -164,14 +166,24 @@ func (c *Conn) handleJoin( return } - for chanName := range strings.SplitSeq(msg.Params[0], ",") { + var keys []string + if len(msg.Params) > 1 { + keys = strings.Split(msg.Params[1], ",") + } + + for idx, chanName := range strings.Split(msg.Params[0], ",") { chanName = strings.TrimSpace(chanName) if !strings.HasPrefix(chanName, "#") { chanName = "#" + chanName } - c.joinChannel(ctx, chanName) + key := "" + if idx < len(keys) { + key = keys[idx] + } + + c.joinChannel(ctx, chanName, key) } } @@ -179,10 +191,10 @@ func (c *Conn) handleJoin( // and delivers topic/names on the wire. func (c *Conn) joinChannel( ctx context.Context, - channel string, + channel, key string, ) { result, err := c.svc.JoinChannel( - ctx, c.sessionID, c.nick, channel, "", + ctx, c.sessionID, c.nick, channel, key, ) if err != nil { c.sendIRCError(err) @@ -763,10 +775,11 @@ func (c *Conn) handleNames( c.deliverNames(ctx, channel, chID) } -// handleList sends the channel list. +// handleList sends the channel list, leaving out +s +// channels the user is not on. func (c *Conn) handleList(ctx context.Context) { - channels, err := c.database.ListAllChannelsWithCounts( - ctx, + channels, err := c.database.ListAllChannelsWithCountsFiltered( + ctx, c.sessionID, ) if err != nil { c.sendNumeric( @@ -886,14 +899,15 @@ func (c *Conn) whoisOper( } } -// whoisChannels sends 319 RPL_WHOISCHANNELS. +// whoisChannels sends 319 RPL_WHOISCHANNELS, leaving out +// +s channels the querier is not on. func (c *Conn) whoisChannels( ctx context.Context, target string, targetID int64, ) { - userChannels, _ := c.database.GetSessionChannels( - ctx, targetID, + userChannels, _ := c.database.GetSessionChannelsFiltered( + ctx, targetID, c.sessionID, ) if len(userChannels) == 0 { return @@ -1230,36 +1244,11 @@ func (c *Conn) handleInvite( targetNick := msg.Params[0] channel := msg.Params[1] - chID, err := c.database.GetChannelByName(ctx, 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, + targetID, err := c.svc.InviteUser( + ctx, c.sessionID, c.nick, targetNick, channel, ) if err != nil { - c.sendNumeric( - irc.ErrNoSuchNick, - targetNick, "No such nick", - ) + c.sendIRCError(err) return } diff --git a/internal/ircserver/integration_test.go b/internal/ircserver/integration_test.go index adb69f4..3646832 100644 --- a/internal/ircserver/integration_test.go +++ b/internal/ircserver/integration_test.go @@ -1239,6 +1239,216 @@ 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", + ) +} + +// 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 // client observing the same channel receives messages from // the other two. diff --git a/internal/service/service.go b/internal/service/service.go index d6edb06..66206a8 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -21,6 +21,7 @@ import ( "sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/globals" + "sneak.berlin/go/neoirc/internal/hashcash" "sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/pkg/irc" ) @@ -30,6 +31,7 @@ const ( msgNoSuchChannel = "No such channel" msgNoSuchNick = "No such nick/channel" msgNotChannelOp = "You're not channel operator" + msgNotOnChannel = "You're not on that channel" ) // maxUserhostNicks is how many nicks one USERHOST answers @@ -63,11 +65,12 @@ type WireConn interface { // Service provides shared business logic for IRC commands. type Service struct { - db *db.Database - broker *broker.Broker - config *config.Config - globals *globals.Globals - log *slog.Logger + db *db.Database + broker *broker.Broker + config *config.Config + globals *globals.Globals + log *slog.Logger + channelHashcash *hashcash.ChannelValidator wireMu sync.Mutex wireConns map[int64]WireConn @@ -76,12 +79,13 @@ type Service struct { // New creates a new Service. func New(params Params) *Service { return &Service{ - db: params.Database, - broker: params.Broker, - config: params.Config, - globals: params.Globals, - log: params.Logger.Get(), - wireConns: make(map[int64]WireConn), + db: params.Database, + broker: params.Broker, + config: params.Config, + globals: params.Globals, + log: params.Logger.Get(), + channelHashcash: hashcash.NewChannelValidator(), + wireConns: make(map[int64]WireConn), } } @@ -95,12 +99,13 @@ func NewTestService( log *slog.Logger, ) *Service { return &Service{ - db: database, - broker: brk, - config: cfg, - globals: globs, - log: log, - wireConns: make(map[int64]WireConn), + db: database, + broker: brk, + config: cfg, + globals: globs, + log: log, + channelHashcash: hashcash.NewChannelValidator(), + wireConns: make(map[int64]WireConn), } } @@ -366,10 +371,11 @@ func excludeSession( return out } -// SendChannelMessage validates membership and moderation, -// then fans out a message to all channel members except -// the sender. Returns the database row ID, message UUID, -// and any error. The dbID lets callers enqueue the same +// SendChannelMessage validates the hashcash stamp of a +// PRIVMSG to a +H channel, membership and moderation, then +// fans out a message to all channel members except the +// sender. Returns the database row ID, message UUID, and +// any error. The dbID lets callers enqueue the same // message to the sender when echo is needed (HTTP // transport). func (s *Service) SendChannelMessage( @@ -387,6 +393,15 @@ func (s *Service) SendChannelMessage( } } + if command == irc.CmdPrivmsg { + err = s.checkChannelHashcash( + ctx, chID, channel, body, meta, + ) + if err != nil { + return 0, "", err + } + } + isMember, _ := s.db.IsChannelMember( ctx, chID, sessionID, ) @@ -568,7 +583,7 @@ func (s *Service) PartChannel( return &IRCError{ irc.ErrNotOnChannel, []string{channel}, - "You're not on that channel", + msgNotOnChannel, } } @@ -610,7 +625,7 @@ func (s *Service) SetTopic( return &IRCError{ irc.ErrNotOnChannel, []string{channel}, - "You're not on that channel", + msgNotOnChannel, } } @@ -713,6 +728,78 @@ func (s *Service) KickUser( return nil } +// InviteUser records an invite of targetNick to channel, +// which JoinChannel consumes, and returns the target's +// session ID. The inviter must be on the channel, and an +// operator if it is +i; the target must exist and not be +// on the channel already. +func (s *Service) InviteUser( + ctx context.Context, + sessionID int64, + nick, targetNick, channel string, +) (int64, error) { + chID, err := s.db.GetChannelByName(ctx, channel) + if err != nil { + return 0, &IRCError{ + irc.ErrNoSuchChannel, + []string{channel}, + msgNoSuchChannel, + } + } + + isMember, memErr := s.db.IsChannelMember( + ctx, chID, sessionID, + ) + if memErr != nil || !isMember { + return 0, &IRCError{ + irc.ErrNotOnChannel, + []string{channel}, + msgNotOnChannel, + } + } + + isInviteOnly, ioErr := s.db.IsChannelInviteOnly(ctx, chID) + if ioErr == nil && isInviteOnly { + isOp, opErr := s.db.IsChannelOperator( + ctx, chID, sessionID, + ) + if opErr != nil || !isOp { + return 0, &IRCError{ + irc.ErrChanOpPrivsNeeded, + []string{channel}, + msgNotChannelOp, + } + } + } + + targetSID, err := s.db.GetSessionByNick(ctx, targetNick) + if err != nil { + return 0, &IRCError{ + irc.ErrNoSuchNick, + []string{targetNick}, + msgNoSuchNick, + } + } + + alreadyIn, aiErr := s.db.IsChannelMember( + ctx, chID, targetSID, + ) + if aiErr == nil && alreadyIn { + return 0, &IRCError{ + irc.ErrUserOnChannel, + []string{targetNick, channel}, + "is already on channel", + } + } + + err = s.db.AddChannelInvite(ctx, chID, targetSID, nick) + if err != nil { + return 0, fmt.Errorf("add invite: %w", err) + } + + return targetSID, nil +} + // ChangeNick changes a user's nickname and broadcasts the // change to all users sharing channels. func (s *Service) ChangeNick( @@ -1169,6 +1256,71 @@ func (s *Service) requireOper( return nil } +// checkChannelHashcash returns ERR_CANNOTSENDTOCHAN unless +// the channel has no +H, or meta carries a hashcash stamp +// for this channel and body with enough bits that has not +// been used before. A stamp that passes is recorded as +// spent. +func (s *Service) checkChannelHashcash( + ctx context.Context, + chID int64, + channel string, + body, meta json.RawMessage, +) error { + bits, err := s.db.GetChannelHashcashBits(ctx, chID) + if err != nil { + return fmt.Errorf("channel hashcash bits: %w", err) + } + + if bits <= 0 { + return nil + } + + stamp := extractHashcashFromMeta(meta) + if stamp == "" { + return &IRCError{ + irc.ErrCannotSendToChan, + []string{channel}, + "Channel requires hashcash proof-of-work", + } + } + + err = s.channelHashcash.ValidateStamp( + stamp, bits, channel, hashcash.BodyHash(body), + ) + if err != nil { + return &IRCError{ + irc.ErrCannotSendToChan, + []string{channel}, + "Invalid hashcash: " + err.Error(), + } + } + + stampKey := hashcash.StampHash(stamp) + + spent, err := s.db.IsHashcashSpent(ctx, stampKey) + if err != nil { + return fmt.Errorf("check spent hashcash: %w", err) + } + + if spent { + return &IRCError{ + irc.ErrCannotSendToChan, + []string{channel}, + "Hashcash stamp already used", + } + } + + err = s.db.RecordSpentHashcash(ctx, stampKey) + if err != nil { + s.log.Error( + "record spent hashcash", "error", err, + ) + } + + return nil +} + // broadcastNickChange notifies channel peers of a nick // change. func (s *Service) broadcastNickChange( @@ -1277,3 +1429,33 @@ func checkJoinRestrictions( return nil } + +// extractHashcashFromMeta parses the meta JSON and +// returns the hashcash stamp string, or empty string +// if not present. +func 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 +} -- 2.54.0 From eceb5f2b2c4efecf1dd219fbfa8b8ce151627a02 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 20 Oct 2026 16:31:28 +0200 Subject: [PATCH 18/40] Compare nicks by exact case everywhere (closes #128) README.md rules that nicks are case-sensitive, but the IRC listener's relay and user MODE on both transports compared nicks ignoring case: an IRC listener user alice never saw a PRIVMSG, NOTICE, JOIN, PART, NICK or QUIT from Alice, and MODE Alice from alice was taken as her own nick. These checks now use ==. Tests cover alice receiving each of these from Alice, MODE Alice from alice getting 502 on both transports, and Alice registering while alice exists. README.md drops the text that described the old behaviour. Model: opus-5-5 --- README.md | 25 +++++++----------- internal/handlers/api_test.go | 3 +++ internal/handlers/utility.go | 5 ++-- internal/handlers/utility_test.go | 4 ++- internal/ircserver/commands.go | 4 +-- internal/ircserver/integration_test.go | 10 ++++--- internal/ircserver/relay.go | 10 +++---- internal/ircserver/relay_test.go | 36 ++++++++++++++++++++++++++ 8 files changed, 66 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index a754b7c..53fe709 100644 --- a/README.md +++ b/README.md @@ -1031,14 +1031,14 @@ e.g. `+o-v alice bob`. | `441` | ERR_USERNOTINCHANNEL | The `+o`/`+v` nick is not in the channel | **User modes:** you can query and change only your own; a nick other than your -own, compared without regard to letter case, gets ERR_USERSDONTMATCH (502). With -no mode string the server sends RPL_UMODEIS (221) with your modes: `+`, `+o`, -`+w` or `+ow`. A change is a mode string, `body[0]` over the HTTP API or the -parameter after the nick on the IRC listener, that starts with `+` or `-`: `w` -(receive `WALLOPS`) can be set or unset, and `o` (server operator) only unset, -since only `OPER` sets it. Any other letter, or a string that names no mode, -gets ERR_UMODEUNKNOWNFLAG (501) and changes nothing. A change that is accepted -is answered with 221 and the new modes; it is not sent to anyone else. +own gets ERR_USERSDONTMATCH (502). With no mode string the server sends +RPL_UMODEIS (221) with your modes: `+`, `+o`, `+w` or `+ow`. A change is a mode +string, `body[0]` over the HTTP API or the parameter after the nick on the IRC +listener, that starts with `+` or `-`: `w` (receive `WALLOPS`) can be set or +unset, and `o` (server operator) only unset, since only `OPER` sets it. Any +other letter, or a string that names no mode, gets ERR_UMODEUNKNOWNFLAG (501) +and changes nothing. A change that is accepted is answered with 221 and the new +modes; it is not sent to anyone else. ```json { "command": "RPL_UMODEIS", "code": 221, "to": "alice", "body": ["+w"] } @@ -2679,8 +2679,7 @@ IRC_LISTEN_ADDR: "" set only over the HTTP API. - **User modes**: `+o` (operator, set only via `OPER`), `+w` (receives `WALLOPS`). `MODE` for any nick other than your own is rejected with - `ERR_USERSDONTMATCH` (502), for both queries and changes. Nick comparison is - case-insensitive. + `ERR_USERSDONTMATCH` (502), for both queries and changes. - **KILL**: an operator's `KILL` broadcasts the victim's `QUIT` to its channel peers, deletes its session, and then sends the victim a `KILL` and `ERROR :Closing Link` before closing its socket. This applies to victims on @@ -2701,12 +2700,6 @@ API reaches IRC listener members as an IRC `MODE` line with its value as a separate parameter, e.g. `MODE #general +k secretpass` or `MODE #general +o bob`. -One gap remains: the IRC listener does not pass on a `PRIVMSG`, `NOTICE`, -`JOIN`, `PART`, `NICK` or `QUIT` whose sender's nick matches the user's own nick -ignoring case, so an IRC listener user `alice` never sees these from `Alice`, -although nicks are case-sensitive. https://git.eeqj.de/sneak/neoirc/issues/128 -tracks it. - ### Docker Usage To expose the IRC port in Docker (the listener is enabled by default on diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index d859b55..e7882f2 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -669,6 +669,9 @@ func TestCreateSessionDuplicate(t *testing.T) { if resp.StatusCode != http.StatusConflict { t.Fatalf("expected 409, got %d", resp.StatusCode) } + + // Nicks are case-sensitive: Alice is not alice. + tserver.createSession("Alice") } // TestEachTestServerHasItsOwnDatabase checks that starting a diff --git a/internal/handlers/utility.go b/internal/handlers/utility.go index 30dcb02..50ee0be 100644 --- a/internal/handlers/utility.go +++ b/internal/handlers/utility.go @@ -254,8 +254,7 @@ func (hdlr *Handlers) handleWallops( // handleUserMode handles MODE for a nick: a query when the // body is empty, otherwise a change by the mode string in -// the body. Only the caller's own nick, in any letter case, -// is allowed. +// the body. Only the caller's own nick is allowed. func (hdlr *Handlers) handleUserMode( writer http.ResponseWriter, request *http.Request, @@ -263,7 +262,7 @@ func (hdlr *Handlers) handleUserMode( nick, target string, bodyLines func() []string, ) { - if !strings.EqualFold(target, nick) { + if target != nick { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrUsersDoNotMatch, nick, nil, diff --git a/internal/handlers/utility_test.go b/internal/handlers/utility_test.go index 7afa834..f5dea41 100644 --- a/internal/handlers/utility_test.go +++ b/internal/handlers/utility_test.go @@ -290,8 +290,10 @@ func TestUserMode(t *testing.T) { target, modeStr, numeric, modes string }{ {nick, "+w", "221", "+w"}, + {"Alice", "", "502", ""}, + {"Alice", "-w", "502", ""}, {nick, "", "221", "+w"}, - {strings.ToUpper(nick), "-w", "221", "+"}, + {nick, "-w", "221", "+"}, {nick, "+z", "501", ""}, {nick, "+o", "501", ""}, {other, "", "502", ""}, diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index fb2e8c5..f7ffa04 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -710,12 +710,12 @@ func (c *Conn) applyChannelModes( // handleUserMode handles MODE for a nick: a query without a // mode string, otherwise a change. Only the client's own -// nick, in any letter case, is allowed. +// nick is allowed. func (c *Conn) handleUserMode( ctx context.Context, msg *Message, ) { - if !strings.EqualFold(msg.Params[0], c.currentNick()) { + if msg.Params[0] != c.currentNick() { c.sendNumeric( irc.ErrUsersDoNotMatch, "Can't change mode for other users", diff --git a/internal/ircserver/integration_test.go b/internal/ircserver/integration_test.go index 3646832..2dde41f 100644 --- a/internal/ircserver/integration_test.go +++ b/internal/ircserver/integration_test.go @@ -1084,9 +1084,9 @@ func TestIntegrationWallops(t *testing.T) { } // TestIntegrationUserMode covers MODE for a nick: changes -// and queries of your own modes in any letter case, refusal -// for another nick, and a rejected mode string changing -// nothing. +// 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() @@ -1106,7 +1106,9 @@ func TestIntegrationUserMode(t *testing.T) { for _, step := range []struct { line, want string }{ - {"MODE ALICE +w", " 221 alice +w"}, + {"MODE alice +w", " 221 alice +w"}, + {"MODE Alice", notYours}, + {"MODE Alice -w", notYours}, {"MODE bob", notYours}, {"MODE bob -w", notYours}, {"MODE alice xw", unknown}, diff --git a/internal/ircserver/relay.go b/internal/ircserver/relay.go index 844e705..0dee85b 100644 --- a/internal/ircserver/relay.go +++ b/internal/ircserver/relay.go @@ -195,7 +195,7 @@ func (c *Conn) deliverTextMessage( c.mu.Unlock() // Don't echo our own messages back. - if strings.EqualFold(from, nick) { + if from == nick { return } @@ -217,7 +217,7 @@ func (c *Conn) deliverJoin(msg *db.IRCMessage) { // Don't echo our own JOINs (we already sent them // during joinChannel). - if strings.EqualFold(msg.From, nick) { + if msg.From == nick { return } @@ -233,7 +233,7 @@ func (c *Conn) deliverPart(msg *db.IRCMessage, text string) { nick := c.nick c.mu.Unlock() - if strings.EqualFold(msg.From, nick) { + if msg.From == nick { return } @@ -258,7 +258,7 @@ func (c *Conn) deliverNickChange( nick := c.nick c.mu.Unlock() - if strings.EqualFold(msg.From, nick) { + if msg.From == nick { return } @@ -276,7 +276,7 @@ func (c *Conn) deliverQuitMsg( nick := c.nick c.mu.Unlock() - if strings.EqualFold(msg.From, nick) { + if msg.From == nick { return } diff --git a/internal/ircserver/relay_test.go b/internal/ircserver/relay_test.go index 579d422..caf0cc8 100644 --- a/internal/ircserver/relay_test.go +++ b/internal/ircserver/relay_test.go @@ -185,3 +185,39 @@ func TestRelayLineBreaks(t *testing.T) { 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) + } + } +} -- 2.54.0 From 0b09cf838cfeab8199bc0db15d1502be7f49074f Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 20 Oct 2026 17:31:31 +0200 Subject: [PATCH 19/40] Check nicks on the IRC listener with the HTTP API's rule (closes #142) The nick rule moves from internal/handlers to internal/service as ValidNick, and the HTTP API and both IRC listener NICK paths call it. The IRC listener used to cut a nick to 32 bytes and accept anything else, so a nick with spaces such as "bob PRIVMSG #inj :hi" made the lines built from it read as another user's PRIVMSG. It now answers a nick the rule refuses with ERR_ERRONEUSNICKNAME (432), before and after registration, and keeps the nick as it was. Model: opus-5-5 --- README.md | 8 ++-- internal/handlers/api.go | 8 +--- internal/ircserver/commands.go | 9 ++++- internal/ircserver/conn.go | 12 ++++-- internal/ircserver/server_test.go | 62 +++++++++++++++++++++++++++++++ internal/service/service.go | 12 ++++++ 6 files changed, 96 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 53fe709..cfb9eb1 100644 --- a/README.md +++ b/README.md @@ -249,9 +249,11 @@ what channel bans (`+b` mode) match against. hold the same nick simultaneously. - Nicks are **case-sensitive** (unlike traditional IRC). `Alice` and `alice` are different nicks. -- Nick length: 1–32 characters. Over the HTTP API a nick starts with a letter or - `_`, followed by letters, digits and `` _-[]\^{}|` ``. The IRC listener cuts a - longer nick to 32 bytes and checks nothing else. +- Nick length: 1–32 characters. A nick starts with a letter or `_`, followed by + letters, digits and `` _-[]\^{}|` ``. The HTTP API and the IRC listener both + apply this rule. On the IRC listener, a `NICK` with any other nick, before or + after registration, gets ERR_ERRONEUSNICKNAME (432) and leaves the nick as it + was. - Nicks are **released when a session is destroyed** (via `QUIT` command or session expiry). There is no nick registration or reservation system. - Nick changes are broadcast to all users sharing a channel with the changer, as diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 8ca7480..a9471a9 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -19,10 +19,6 @@ import ( "sneak.berlin/go/neoirc/pkg/irc" ) -var validNickRe = regexp.MustCompile( - `^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`, -) - var validUsernameRe = regexp.MustCompile( `^[a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{1,32}$`, ) @@ -211,7 +207,7 @@ func (hdlr *Handlers) handleCreateSession( payload.Nick = strings.TrimSpace(payload.Nick) - if !validNickRe.MatchString(payload.Nick) { + if !service.ValidNick(payload.Nick) { hdlr.respondError( writer, request, "invalid nick format", @@ -1551,7 +1547,7 @@ func (hdlr *Handlers) handleNick( newNick := strings.TrimSpace(lines[0]) - if !validNickRe.MatchString(newNick) { + if !service.ValidNick(newNick) { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrErroneusNickname, nick, []string{newNick}, diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index f7ffa04..639c52e 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -71,8 +71,13 @@ func (c *Conn) handleNick( } newNick := msg.Params[0] - if len(newNick) > maxNickLen { - newNick = newNick[:maxNickLen] + if !service.ValidNick(newNick) { + c.sendNumeric( + irc.ErrErroneusNickname, + newNick, "Erroneous nickname", + ) + + return } oldMask := c.hostmask() diff --git a/internal/ircserver/conn.go b/internal/ircserver/conn.go index bd2adcf..8fc9224 100644 --- a/internal/ircserver/conn.go +++ b/internal/ircserver/conn.go @@ -27,7 +27,6 @@ const ( pollInterval = 100 * time.Millisecond pingInterval = 90 * time.Second pongDeadline = 30 * time.Second - maxNickLen = 32 minPasswordLen = 8 maxHashcashBits = 40 ) @@ -426,11 +425,16 @@ func (c *Conn) handlePreRegistration( return } - c.nick = msg.Params[0] - if len(c.nick) > maxNickLen { - c.nick = c.nick[:maxNickLen] + if !service.ValidNick(msg.Params[0]) { + c.sendNumeric( + irc.ErrErroneusNickname, + msg.Params[0], "Erroneous nickname", + ) + + return } + c.nick = msg.Params[0] c.gotNick = true case irc.CmdUser: if len(msg.Params) < 4 { //nolint:mnd diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index e77a996..6aba3a8 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -519,6 +519,68 @@ func TestDuplicateNick(t *testing.T) { 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) { t.Parallel() diff --git a/internal/service/service.go b/internal/service/service.go index 66206a8..dabadd7 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -44,6 +44,18 @@ var validChannelRe = regexp.MustCompile( `^#[a-zA-Z0-9_\-]{1,63}$`, ) +// validNickRe matches the nicks both transports accept: a +// letter or _, then up to 31 letters, digits or +// _-[]\^{}|`. +var validNickRe = regexp.MustCompile( + `^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`, +) + +// ValidNick reports whether validNickRe matches nick. +func ValidNick(nick string) bool { + return validNickRe.MatchString(nick) +} + // Params defines the dependencies for creating a Service. type Params struct { fx.In -- 2.54.0 From 5100b9a665029877dabeb265bb497b7a4badec2f Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 20 Oct 2026 18:02:03 +0200 Subject: [PATCH 20/40] Remove the relay's broker wait channel after each tick (closes #151) The IRC relay registers a broker wait channel on each pass of its loop, and when the 100 ms ticker fired instead of a notification it looped without removing that channel. An idle IRC listener connection added ten channels a second to the broker, and all but the last stayed after the connection closed. The ticker case now removes its channel, as the HTTP long-poll does after its timer. Broker.ListenerCount returns how many wait channels a user holds, so the test can check that an idle connection holds at most one, and none after it closes. Model: opus-5-5 Co-authored-by: clawbot --- internal/broker/broker.go | 10 +++++++ internal/broker/broker_test.go | 33 +++++++++++++++++++++ internal/ircserver/export_test.go | 4 +++ internal/ircserver/relay.go | 5 +++- internal/ircserver/relay_test.go | 48 +++++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 1 deletion(-) diff --git a/internal/broker/broker.go b/internal/broker/broker.go index 6974110..2b6b976 100644 --- a/internal/broker/broker.go +++ b/internal/broker/broker.go @@ -47,6 +47,16 @@ 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). func (b *Broker) Remove( userID int64, diff --git a/internal/broker/broker_test.go b/internal/broker/broker_test.go index f2443c0..d439a50 100644 --- a/internal/broker/broker_test.go +++ b/internal/broker/broker_test.go @@ -59,6 +59,39 @@ 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) { t.Parallel() diff --git a/internal/ircserver/export_test.go b/internal/ircserver/export_test.go index 4e665b2..60d039b 100644 --- a/internal/ircserver/export_test.go +++ b/internal/ircserver/export_test.go @@ -60,6 +60,10 @@ func (s *Server) Listener() net.Listener { // 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 + // NewTestConn returns a Conn for nick on tcpConn, without a // server, database or service behind it. func NewTestConn( diff --git a/internal/ircserver/relay.go b/internal/ircserver/relay.go index 0dee85b..bbb5309 100644 --- a/internal/ircserver/relay.go +++ b/internal/ircserver/relay.go @@ -42,7 +42,10 @@ func (c *Conn) relayMessages(ctx context.Context) { case <-waitCh: // New message notification — loop back. case <-ticker.C: - // Periodic check. + // Periodic check. Nothing used waitCh, so take it + // out of the broker before the next pass adds + // another. + c.brk.Remove(c.sessionID, waitCh) case <-ctx.Done(): c.brk.Remove(c.sessionID, waitCh) diff --git a/internal/ircserver/relay_test.go b/internal/ircserver/relay_test.go index caf0cc8..8ab07a4 100644 --- a/internal/ircserver/relay_test.go +++ b/internal/ircserver/relay_test.go @@ -5,6 +5,9 @@ import ( "slices" "strings" "testing" + "time" + + "sneak.berlin/go/neoirc/internal/ircserver" ) // queueMessage queues a message for the user with nick @@ -44,6 +47,51 @@ func (env *testEnv) queueMessage( env.brk.Notify(sessionID) } +// TestRelayKeepsOneWaitChannel checks that an idle IRC +// listener connection holds at most one broker wait channel +// across several relay ticks, and none once it closes. +func TestRelayKeepsOneWaitChannel(t *testing.T) { + t.Parallel() + + const idleTicks = 5 + + 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) + } + + for tick := range idleTicks { + 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) + } + } + + 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) + } +} + // 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. -- 2.54.0 From 164fc93a48b4f5a12104ea4c41c4b69042e352e1 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 20 Oct 2026 19:48:35 +0200 Subject: [PATCH 21/40] Bind history to the recipient session and channel, not the name (closes #148) History was selected by msg_to, so whoever next held a released nick, or created a deleted channel again under its old name, could read the earlier holder's messages. Each message row now records the session a DM or INVITE was sent to (to_session_id) and the channel a channel message was sent to (to_channel_id), and GET /api/v1/history selects by the one that applies, using its index. A session's DM history now also follows it across nick changes. A DM or channel message whose recipient is deleted before it is stored gets the unknown nick or channel error. Model: opus-5-5 --- README.md | 56 +++--- internal/db/errors.go | 11 ++ internal/db/queries.go | 50 ++++-- internal/db/queries_test.go | 101 ++++++++++- internal/db/schema/001_initial.sql | 10 +- internal/handlers/api.go | 60 ++++--- internal/handlers/api_test.go | 280 +++++++++++++++++++++++++---- internal/ircserver/commands.go | 2 +- internal/ircserver/relay_test.go | 2 +- internal/service/service.go | 58 +++--- internal/service/service_test.go | 74 +++++++- 11 files changed, 569 insertions(+), 135 deletions(-) diff --git a/README.md b/README.md index cfb9eb1..8fc3ff5 100644 --- a/README.md +++ b/README.md @@ -376,9 +376,10 @@ balancer, and CDN handles it correctly. - DMs are **fan-out to both sender and recipient** — the sender sees their own DM echoed back in their message queue, enabling multi-client consistency (your laptop sees DMs you sent from your phone). -- DM history is stored in the `messages` table with the recipient nick as the - `msg_to` field. This means DM history is queryable per-nick, but if a user - changes their nick, old DMs are associated with the old nick. +- DM history is stored in the `messages` table with the recipient's session as + the `to_session_id` field. A user's DM history therefore follows them across + nick changes, and a user who later takes a released nick sees none of the DMs + sent to its previous holder. - DMs are **not stored long-term** by default — they follow the same rotation policy as channel messages. @@ -1839,18 +1840,21 @@ Fetch historical messages for a channel. Returns messages in chronological order **Query Parameters:** -| Param | Type | Default | Description | -| -------- | ------- | ---------- | ------------------------------------------------------------------------------------------------ | -| `target` | string | (required) | Channel name (e.g., `#general`) | -| `before` | string | (none) | The `id` of a message sent to `target`; return only messages older than it. Omit for the latest. | -| `limit` | integer | `50` | Maximum messages to return, 1–500; any other value gives 50. | +| Param | Type | Default | Description | +| -------- | ------- | ---------- | ----------------------------------------------------------------------------------------------- | +| `target` | string | (required) | Channel name (e.g., `#general`) | +| `before` | string | (none) | The `id` of a message in this history; return only messages older than it. Omit for the latest. | +| `limit` | integer | `50` | Maximum messages to return, 1–500; any other value gives 50. | To page back, pass the `id` of the first (oldest) message of a page as `before` for the next; an empty page means there is nothing older. -`target` may also be your own nick, which returns the DMs sent to you. Errors: -`400` `target required`, `400` `unknown before id` when no message with that -`id` was sent to `target`, `404` `channel not found`, `403` +`target` may also be your own nick, which returns the DMs sent to your session, +including those sent under its earlier nicks. A channel created again after its +last member left starts with no history, and a user who takes a released nick +gets none of the DMs sent to its previous holder. Errors: `400` +`target required`, `400` `unknown before id` when no message with that `id` is +in this history, `404` `channel not found`, `403` `not a member of this channel`, and `403` `forbidden` for any other nick. **Response:** `200 OK` @@ -2508,19 +2512,25 @@ Unique constraint on `(channel_id, session_id)`. #### `messages` -| Column | Type | Description | -| ------------ | -------- | --------------------------------------------------------------- | -| `id` | INTEGER | Primary key (auto-increment). Internal ID for queue references. | -| `uuid` | TEXT | UUID v4, exposed to clients as the message `id` | -| `command` | TEXT | IRC command (`PRIVMSG`, `JOIN`, etc.) | -| `msg_from` | TEXT | Sender nick | -| `msg_to` | TEXT | Target (`#channel` or nick) | -| `params` | TEXT | JSON-encoded IRC-style positional parameters | -| `body` | TEXT | JSON-encoded body (array or object) | -| `meta` | TEXT | JSON-encoded metadata | -| `created_at` | DATETIME | Server timestamp | +| Column | Type | Description | +| --------------- | -------- | ----------------------------------------------------------------------------------- | +| `id` | INTEGER | Primary key (auto-increment). Internal ID for queue references. | +| `uuid` | TEXT | UUID v4, exposed to clients as the message `id` | +| `command` | TEXT | IRC command (`PRIVMSG`, `JOIN`, etc.) | +| `msg_from` | TEXT | Sender nick | +| `msg_to` | TEXT | Target (`#channel` or nick) | +| `to_session_id` | INTEGER | FK → sessions.id (set to null on delete): the session a DM or `INVITE` was sent to | +| `to_channel_id` | INTEGER | FK → channels.id (set to null on delete): the channel a channel message was sent to | +| `params` | TEXT | JSON-encoded IRC-style positional parameters | +| `body` | TEXT | JSON-encoded body (array or object) | +| `meta` | TEXT | JSON-encoded metadata | +| `created_at` | DATETIME | Server timestamp | -Indexes on `(msg_to, id)` and `(created_at)`. +Indexes on `(to_session_id, id)`, `(to_channel_id, id)` and `(created_at)`. +`to_session_id` and `to_channel_id` are null on a message sent to neither, such +as `QUIT`. `GET /api/v1/history` selects by one of these two columns rather than +by `msg_to`, because nicks and channel names are reused once released, and +session and channel ids are not. #### `spent_hashcash` diff --git a/internal/db/errors.go b/internal/db/errors.go index de7768c..c4f1da4 100644 --- a/internal/db/errors.go +++ b/internal/db/errors.go @@ -17,3 +17,14 @@ func IsUniqueConstraintError(err error) bool { 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 +} diff --git a/internal/db/queries.go b/internal/db/queries.go index a552d95..be27ff3 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "strconv" "strings" "time" @@ -940,9 +941,14 @@ func (database *Database) GetSessionChannelIDs( } // InsertMessage stores a message and returns its DB ID. +// toSessionID is the session a DM or INVITE is sent to and +// toChannelID the channel a channel message is sent to; +// GetHistory selects by them. Pass 0 for one that does not +// apply, which stores NULL. func (database *Database) InsertMessage( ctx context.Context, command, from, target string, + toSessionID, toChannelID int64, params json.RawMessage, body json.RawMessage, meta json.RawMessage, @@ -965,9 +971,12 @@ func (database *Database) InsertMessage( res, err := database.conn.ExecContext(ctx, `INSERT INTO messages (uuid, command, msg_from, msg_to, + to_session_id, to_channel_id, params, body, meta, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, NULLIF(?, 0), NULLIF(?, 0), + ?, ?, ?, ?)`, msgUUID, command, from, target, + toSessionID, toChannelID, string(params), string(body), string(meta), now) if err != nil { return 0, "", fmt.Errorf( @@ -1058,26 +1067,31 @@ func (database *Database) PollMessages( return msgs, lastQID, nil } -// GetHistory returns message history for a target. A -// non-empty before is the id of a message sent to target, -// and only messages older than it are returned; an id with -// no such message gives an error wrapping sql.ErrNoRows. +// GetHistory returns the PRIVMSG history of one recipient: +// the DMs sent to session toSessionID, or the messages sent +// to channel toChannelID. Pass 0 for the other. A non-empty +// before is the id of a PRIVMSG in that history, and only +// messages older than it are returned; an id with no such +// message gives an error wrapping sql.ErrNoRows. func (database *Database) GetHistory( ctx context.Context, - target, before string, + toSessionID, toChannelID int64, + before string, limit int, ) ([]IRCMessage, error) { if limit <= 0 { limit = defaultHistLimit } - var beforeID int64 + // With no before, every message is older than this. + beforeID := int64(math.MaxInt64) if before != "" { err := database.conn.QueryRowContext(ctx, `SELECT id FROM messages - WHERE uuid = ? AND msg_to = ?`, - before, target, + WHERE uuid = ? AND command = 'PRIVMSG' + AND (to_session_id = ? OR to_channel_id = ?)`, + before, toSessionID, toChannelID, ).Scan(&beforeID) if err != nil { return nil, fmt.Errorf( @@ -1087,7 +1101,7 @@ func (database *Database) GetHistory( } rows, err := database.queryHistory( - ctx, target, beforeID, limit, + ctx, toSessionID, toChannelID, beforeID, limit, ) if err != nil { return nil, err @@ -1107,21 +1121,23 @@ func (database *Database) GetHistory( return msgs, nil } +// queryHistory selects by to_session_id when toSessionID is +// set and by to_channel_id otherwise, so that the column's +// (column, id) index serves the order and the limit. func (database *Database) queryHistory( ctx context.Context, - target string, - beforeID int64, + toSessionID, toChannelID, beforeID int64, limit int, ) (*sql.Rows, error) { - if beforeID > 0 { + if toSessionID != 0 { rows, err := database.conn.QueryContext(ctx, `SELECT id, uuid, command, msg_from, msg_to, params, body, meta, created_at FROM messages - WHERE msg_to = ? AND id < ? + WHERE to_session_id = ? AND id < ? AND command = 'PRIVMSG' ORDER BY id DESC LIMIT ?`, - target, beforeID, limit) + toSessionID, beforeID, limit) if err != nil { return nil, fmt.Errorf( "query history: %w", err, @@ -1135,10 +1151,10 @@ func (database *Database) queryHistory( `SELECT id, uuid, command, msg_from, msg_to, params, body, meta, created_at FROM messages - WHERE msg_to = ? + WHERE to_channel_id = ? AND id < ? AND command = 'PRIVMSG' ORDER BY id DESC LIMIT ?`, - target, limit) + toChannelID, beforeID, limit) if err != nil { return nil, fmt.Errorf("query history: %w", err) } diff --git a/internal/db/queries_test.go b/internal/db/queries_test.go index 40e8f4d..746f3d7 100644 --- a/internal/db/queries_test.go +++ b/internal/db/queries_test.go @@ -619,7 +619,7 @@ func TestInsertMessage(t *testing.T) { body := json.RawMessage(`["hello"]`) dbID, msgUUID, err := database.InsertMessage( - ctx, "PRIVMSG", "poller", "#test", nil, body, nil, + ctx, "PRIVMSG", "poller", "#test", 0, 0, nil, body, nil, ) if err != nil { t.Fatal(err) @@ -653,7 +653,7 @@ func TestPollMessages(t *testing.T) { body := json.RawMessage(`["hello"]`) dbID, _, err := database.InsertMessage( - ctx, "PRIVMSG", "poller", "#test", nil, body, nil, + ctx, "PRIVMSG", "poller", "#test", 0, 0, nil, body, nil, ) if err != nil { t.Fatal(err) @@ -706,11 +706,16 @@ func TestGetHistory(t *testing.T) { database := setupTestDB(t) ctx := t.Context() + chID, err := database.GetOrCreateChannel(ctx, "#hist") + if err != nil { + t.Fatal(err) + } + const msgCount = 10 for range msgCount { - _, _, err := database.InsertMessage( - ctx, "PRIVMSG", "user", "#hist", + _, _, err = database.InsertMessage( + ctx, "PRIVMSG", "user", "#hist", 0, chID, nil, json.RawMessage(`["msg"]`), nil, ) if err != nil { @@ -721,7 +726,7 @@ func TestGetHistory(t *testing.T) { const histLimit = 5 msgs, err := database.GetHistory( - ctx, "#hist", "", histLimit, + ctx, 0, chID, "", histLimit, ) if err != nil { t.Fatal(err) @@ -737,6 +742,90 @@ func TestGetHistory(t *testing.T) { } } +// TestGetHistoryReusedNames checks that history follows the +// 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() + + database := setupTestDB(t) + ctx := t.Context() + + historyLen := func(toSessionID, toChannelID int64) int { + t.Helper() + + 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 { + t.Fatal(err) + } + + oldChID, err := database.GetOrCreateChannel(ctx, "#reused") + 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 { + t.Fatal(err) + } + + _, _, err = database.InsertMessage( + ctx, "PRIVMSG", "sender", "#reused", 0, oldChID, + nil, body, nil, + ) + if err != nil { + t.Fatal(err) + } + + if historyLen(oldSID, 0) != 1 || historyLen(0, oldChID) != 1 { + t.Fatal("expected one DM and one channel message") + } + + err = database.DeleteSession(ctx, oldSID) + if err != nil { + t.Fatal(err) + } + + err = database.DeleteChannelIfEmpty(ctx, oldChID) + if err != nil { + 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") + } +} + func TestDeleteSession(t *testing.T) { t.Parallel() @@ -863,7 +952,7 @@ func TestEnqueueToClient(t *testing.T) { body := json.RawMessage(`["test"]`) dbID, _, err := database.InsertMessage( - ctx, "PRIVMSG", "sender", "#ch", nil, body, nil, + ctx, "PRIVMSG", "sender", "#ch", 0, 0, nil, body, nil, ) if err != nil { t.Fatal(err) diff --git a/internal/db/schema/001_initial.sql b/internal/db/schema/001_initial.sql index 1a1b27f..fd66e15 100644 --- a/internal/db/schema/001_initial.sql +++ b/internal/db/schema/001_initial.sql @@ -85,19 +85,25 @@ CREATE TABLE IF NOT EXISTS channel_members ( UNIQUE(channel_id, session_id) ); --- Messages: IRC envelope format +-- Messages: IRC envelope format. to_session_id is the session a DM +-- 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 ( id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, command TEXT NOT NULL DEFAULT 'PRIVMSG', msg_from 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 '[]', body TEXT NOT NULL DEFAULT '[]', meta TEXT NOT NULL DEFAULT '{}', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(msg_to, id); +CREATE INDEX IF NOT EXISTS idx_messages_to_session ON messages(to_session_id, 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); -- Spent hashcash tokens for replay prevention (1-year TTL) diff --git a/internal/handlers/api.go b/internal/handlers/api.go index a9471a9..9e10b46 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -566,7 +566,7 @@ func (hdlr *Handlers) enqueueNumeric( } dbID, _, insertErr := hdlr.params.Database.InsertMessage( - ctx, command, hdlr.serverName(), nick, + ctx, command, hdlr.serverName(), nick, 0, 0, paramsJSON, json.RawMessage(body), nil, ) if insertErr != nil { @@ -652,7 +652,7 @@ func (hdlr *Handlers) initChannelState( dbID, _, insErr := hdlr.params.Database. InsertMessage( ctx, "JOIN", nick, chanInfo.Name, - nil, nil, nil, + 0, chanInfo.ID, nil, nil, nil, ) if insErr != nil { hdlr.log.Error( @@ -2404,7 +2404,7 @@ func (hdlr *Handlers) executeBanChange( ) if mErr == nil { _ = hdlr.fanOutSilent( - request, irc.CmdMode, nick, channel, + request, irc.CmdMode, nick, channel, 0, chID, json.RawMessage(modeBody), memberIDs, ) } @@ -2522,7 +2522,7 @@ func (hdlr *Handlers) setChannelKeyMode( modeBody, mErr := json.Marshal([]string{"+k", key}) if mErr == nil { _ = hdlr.fanOutSilent( - request, irc.CmdMode, nick, channel, + request, irc.CmdMode, nick, channel, 0, chID, json.RawMessage(modeBody), memberIDs, ) } @@ -2571,7 +2571,7 @@ func (hdlr *Handlers) clearChannelKeyMode( modeBody, mErr := json.Marshal([]string{"-k", "*"}) if mErr == nil { _ = hdlr.fanOutSilent( - request, irc.CmdMode, nick, channel, + request, irc.CmdMode, nick, channel, 0, chID, json.RawMessage(modeBody), memberIDs, ) } @@ -2646,7 +2646,7 @@ func (hdlr *Handlers) setChannelLimitMode( ) if mErr == nil { _ = hdlr.fanOutSilent( - request, irc.CmdMode, nick, channel, + request, irc.CmdMode, nick, channel, 0, chID, json.RawMessage(modeBody), memberIDs, ) } @@ -2696,7 +2696,7 @@ func (hdlr *Handlers) clearChannelLimitMode( modeBody, mErr := json.Marshal([]string{"-l"}) if mErr == nil { _ = hdlr.fanOutSilent( - request, irc.CmdMode, nick, channel, + request, irc.CmdMode, nick, channel, 0, chID, json.RawMessage(modeBody), memberIDs, ) } @@ -2755,7 +2755,7 @@ func (hdlr *Handlers) handleInvite( if mErr == nil { _ = hdlr.fanOutSilent( request, irc.CmdInvite, nick, targetNick, - json.RawMessage(invBody), + targetSID, 0, json.RawMessage(invBody), []int64{targetSID}, ) } @@ -3250,9 +3250,10 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc { return } - if !hdlr.canAccessHistory( + toSessionID, toChannelID, ok := hdlr.canAccessHistory( writer, request, sessionID, nick, target, - ) { + ) + if !ok { return } @@ -3264,7 +3265,7 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc { } msgs, err := hdlr.params.Database.GetHistory( - request.Context(), target, + request.Context(), toSessionID, toChannelID, request.URL.Query().Get("before"), limit, ) if errors.Is(err, sql.ErrNoRows) { @@ -3297,17 +3298,23 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc { } // canAccessHistory verifies the user can read history -// for the given target (channel or DM participant). +// for the given target (channel or DM participant) and +// 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( writer http.ResponseWriter, request *http.Request, sessionID int64, nick, target string, -) bool { +) (int64, int64, bool) { if strings.HasPrefix(target, "#") { - return hdlr.canAccessChannelHistory( + chID, ok := hdlr.canAccessChannelHistory( writer, request, sessionID, target, ) + + return 0, chID, ok } // DM history: only allow if the target is the @@ -3319,18 +3326,20 @@ func (hdlr *Handlers) canAccessHistory( http.StatusForbidden, ) - return false + return 0, 0, false } - return true + return sessionID, 0, true } +// canAccessChannelHistory returns the id of the channel +// named target if the user is a member of it. func (hdlr *Handlers) canAccessChannelHistory( writer http.ResponseWriter, request *http.Request, sessionID int64, target string, -) bool { +) (int64, bool) { chID, err := hdlr.params.Database.GetChannelByName( request.Context(), target, ) @@ -3341,7 +3350,7 @@ func (hdlr *Handlers) canAccessChannelHistory( http.StatusNotFound, ) - return false + return 0, false } isMember, err := hdlr.params.Database.IsChannelMember( @@ -3357,7 +3366,7 @@ func (hdlr *Handlers) canAccessChannelHistory( http.StatusInternalServerError, ) - return false + return 0, false } if !isMember { @@ -3367,10 +3376,10 @@ func (hdlr *Handlers) canAccessChannelHistory( http.StatusForbidden, ) - return false + return 0, false } - return true + return chID, true } // HandleLogout deletes the authenticated client's token @@ -3679,17 +3688,19 @@ func (hdlr *Handlers) deliverWhoisIdle( } // fanOut inserts a message and enqueues it to all given -// sessions. +// sessions. toSessionID and toChannelID are the recipient +// InsertMessage records for history, 0 when there is none. func (hdlr *Handlers) fanOut( request *http.Request, command, from, target string, + toSessionID, toChannelID int64, body json.RawMessage, meta json.RawMessage, sessionIDs []int64, ) (string, error) { dbID, msgUUID, err := hdlr.params.Database.InsertMessage( request.Context(), command, from, target, - nil, body, meta, + toSessionID, toChannelID, nil, body, meta, ) if err != nil { return "", fmt.Errorf("insert message: %w", err) @@ -3714,12 +3725,13 @@ func (hdlr *Handlers) fanOut( func (hdlr *Handlers) fanOutSilent( request *http.Request, command, from, target string, + toSessionID, toChannelID int64, body json.RawMessage, sessionIDs []int64, ) error { _, err := hdlr.fanOut( request, command, from, target, - body, nil, sessionIDs, + toSessionID, toChannelID, body, nil, sessionIDs, ) return err diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index e7882f2..31861f3 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -1551,6 +1551,60 @@ func (tserver *testServer) getHistory( return resp.StatusCode, msgs } +// getAllHistory pages back through target's history +// pageSize messages at a time and returns the message texts, +// oldest first, and the number of pages that held any. It +// stops after maxPages pages, so a before that is ignored +// cannot loop forever. +func (tserver *testServer) getAllHistory( + token, target string, + pageSize, maxPages int, +) ([]string, int) { + tserver.t.Helper() + + var ( + got []string + pages int + ) + + // The first page has no before; each later one passes the + // id of the oldest message on the page fetched before it. + before := "" + + for range maxPages { + status, page := tserver.getHistory( + token, target, before, pageSize, + ) + if status != http.StatusOK { + tserver.t.Fatalf("expected 200, got %d", status) + } + + if len(page) == 0 { + break + } + + pages++ + + texts := make([]string, 0, len(page)) + + for _, msg := range page { + var body []string + + err := json.Unmarshal(msg.Body, &body) + if err != nil { + tserver.t.Fatalf("decode body: %v", err) + } + + texts = append(texts, body...) + } + + got = slices.Insert(got, 0, texts...) + before = page[0].ID + } + + return got, pages +} + func TestHistoryPaging(t *testing.T) { const ( channel = "#paging" @@ -1579,46 +1633,10 @@ func TestHistoryPaging(t *testing.T) { }) } - var ( - got []string - pages int + got, pages := tserver.getAllHistory( + token, channel, pageSize, msgCount, ) - // The first page has no before; each later one passes the - // id of the oldest message on the page fetched before it. - before := "" - - for range msgCount { - status, page := tserver.getHistory( - token, channel, before, pageSize, - ) - if status != http.StatusOK { - t.Fatalf("expected 200, got %d", status) - } - - if len(page) == 0 { - break - } - - pages++ - - texts := make([]string, 0, len(page)) - - for _, msg := range page { - var body []string - - err := json.Unmarshal(msg.Body, &body) - if err != nil { - t.Fatalf("decode body: %v", err) - } - - texts = append(texts, body...) - } - - got = slices.Insert(got, 0, texts...) - before = page[0].ID - } - if pages != wantPages { t.Fatalf( "expected %d pages, got %d", wantPages, pages, @@ -1630,6 +1648,81 @@ func TestHistoryPaging(t *testing.T) { } } +// TestHistoryDMPaging pages through a nick's DM history, +// which holds the DMs its session got under an earlier nick +// and none sent to another session, and checks that the id +// of a DM sent to another session is not accepted as before. +func TestHistoryDMPaging(t *testing.T) { + const ( + firstNick = "readerFirst" + laterNick = "readerLater" + otherNick = "otherReader" + // DMs sent to firstNick before the nick change. + sentEarly = 2 + pageSize = 2 + wantPages = 3 + ) + + tserver := newTestServer(t) + senderToken := tserver.createSession("dmPager") + readerToken := tserver.createSession(firstNick) + otherToken := tserver.createSession(otherNick) + + sendDM := func(to, text string) { + tserver.sendCommand(senderToken, map[string]any{ + commandKey: privmsgCmd, + toKey: to, + bodyKey: []string{text}, + }) + } + + want := []string{"dm 1", "dm 2", "dm 3", "dm 4", "dm 5"} + + for _, text := range want[:sentEarly] { + sendDM(firstNick, text) + } + + _, result := tserver.sendCommand(readerToken, map[string]any{ + commandKey: irc.CmdNick, + bodyKey: []string{laterNick}, + }) + if result[nickKey] != laterNick { + t.Fatalf("expected nick %s, got %v", laterNick, result) + } + + sendDM(otherNick, "not for the reader") + + for _, text := range want[sentEarly:] { + sendDM(laterNick, text) + } + + got, pages := tserver.getAllHistory( + readerToken, laterNick, pageSize, len(want), + ) + + if pages != wantPages { + t.Fatalf( + "expected %d pages, got %d", wantPages, pages, + ) + } + + if !slices.Equal(got, want) { + t.Fatalf("expected %q, got %q", want, got) + } + + _, otherDMs := tserver.getHistory(otherToken, otherNick, "", 1) + if len(otherDMs) != 1 { + t.Fatalf("expected 1 DM, got %d", len(otherDMs)) + } + + status, _ := tserver.getHistory( + readerToken, laterNick, otherDMs[0].ID, pageSize, + ) + if status != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", status) + } +} + func TestHistoryUnknownBefore(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession("lost") @@ -1654,9 +1747,25 @@ func TestHistoryUnknownBefore(t *testing.T) { ) } + // History holds only PRIVMSG, so the id of the JOIN to + // #here is not in it. + var joinID string + + queued, _ := tserver.pollMessages(token, 0) + for _, msg := range queued { + if msg[commandKey] == joinCmd && msg[toKey] == "#here" { + joinID, _ = msg["id"].(string) + } + } + + if joinID == "" { + t.Fatal("expected a JOIN to #here in the queue") + } + cases := []struct{ name, before string }{ {"made-up id", "no-such-message"}, {"id from another channel", elsewhere[0].ID}, + {"id of a JOIN", joinID}, } for _, tc := range cases { @@ -1671,6 +1780,101 @@ func TestHistoryUnknownBefore(t *testing.T) { } } +// TestHistoryReusedNick checks that a user who takes a nick +// released by QUIT does not get the DMs sent to its previous +// holder. +func TestHistoryReusedNick(t *testing.T) { + const victim = "victimV" + + tserver := newTestServer(t) + senderToken := tserver.createSession("dmA") + victimToken := tserver.createSession(victim) + newcomerToken := tserver.createSession("newcomer") + + tserver.sendCommand(senderToken, map[string]any{ + commandKey: privmsgCmd, + toKey: victim, + bodyKey: []string{"for the first victimV only"}, + }) + + _, msgs := tserver.getHistory(victimToken, victim, "", 10) + if len(msgs) != 1 { + t.Fatalf("expected 1 DM for %s, got %d", victim, len(msgs)) + } + + tserver.sendCommand(victimToken, map[string]any{ + commandKey: irc.CmdQuit, + }) + + _, result := tserver.sendCommand(newcomerToken, map[string]any{ + commandKey: irc.CmdNick, + bodyKey: []string{victim}, + }) + if result[nickKey] != victim { + t.Fatalf("expected nick %s, got %v", victim, result) + } + + status, msgs := tserver.getHistory(newcomerToken, victim, "", 10) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + if len(msgs) != 0 { + t.Fatalf("expected no history, got %v", msgs) + } +} + +// TestHistoryRecreatedChannel checks that a channel created +// again under the name of a deleted one starts with no +// history, even when the old one had a key. +func TestHistoryRecreatedChannel(t *testing.T) { + const channel = "#priv" + + tserver := newTestServer(t) + ownerToken := tserver.createSession("roomR") + strangerToken := tserver.createSession("stranger") + + tserver.sendCommand(ownerToken, map[string]any{ + commandKey: joinCmd, toKey: channel, + }) + tserver.sendCommand(ownerToken, map[string]any{ + commandKey: irc.CmdMode, + toKey: channel, + bodyKey: []string{"+k", "topsecret"}, + }) + tserver.sendCommand(ownerToken, map[string]any{ + commandKey: privmsgCmd, + toKey: channel, + bodyKey: []string{"members only"}, + }) + + _, msgs := tserver.getHistory(ownerToken, channel, "", 10) + if len(msgs) != 1 { + t.Fatalf("expected 1 message, got %d", len(msgs)) + } + + // The last member leaving deletes the channel. + tserver.sendCommand(ownerToken, map[string]any{ + commandKey: irc.CmdPart, toKey: channel, + }) + + _, result := tserver.sendCommand(strangerToken, map[string]any{ + commandKey: joinCmd, toKey: channel, + }) + if result[statusKey] != joinedStatus { + t.Fatalf("expected the join to succeed, got %v", result) + } + + status, msgs := tserver.getHistory(strangerToken, channel, "", 10) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + if len(msgs) != 0 { + t.Fatalf("expected no history, got %v", msgs) + } +} + func TestChannelList(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession("lister") diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index 639c52e..960dac7 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -1268,7 +1268,7 @@ func (c *Conn) handleInvite( ) _, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast - ctx, "INVITE", c.nick, targetNick, + ctx, "INVITE", c.nick, targetNick, targetID, 0, nil, body, nil, []int64{targetID}, ) } diff --git a/internal/ircserver/relay_test.go b/internal/ircserver/relay_test.go index 8ab07a4..767fb69 100644 --- a/internal/ircserver/relay_test.go +++ b/internal/ircserver/relay_test.go @@ -28,7 +28,7 @@ func (env *testEnv) queueMessage( } msgID, _, err := env.database.InsertMessage( - ctx, command, from, to, nil, bodyJSON, nil, + ctx, command, from, to, 0, 0, nil, bodyJSON, nil, ) if err != nil { t.Fatalf("insert message: %v", err) diff --git a/internal/service/service.go b/internal/service/service.go index dabadd7..4a43946 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -311,7 +311,7 @@ func (s *Service) SendWallops( } _, _, err = s.FanOut( - ctx, irc.CmdWallops, nick, "*", + ctx, irc.CmdWallops, nick, "*", 0, 0, nil, body, nil, recipients, ) if err != nil { @@ -344,15 +344,19 @@ type DirectMsgResult struct { } // FanOut inserts a message and enqueues it to all given -// session IDs, notifying each via the broker. +// session IDs, notifying each via the broker. toSessionID +// and toChannelID are the recipient InsertMessage records +// for history, 0 when there is none. func (s *Service) FanOut( ctx context.Context, command, from, to string, + toSessionID, toChannelID int64, params, body, meta json.RawMessage, sessionIDs []int64, ) (int64, string, error) { dbID, msgUUID, err := s.db.InsertMessage( - ctx, command, from, to, params, body, meta, + ctx, command, from, to, + toSessionID, toChannelID, params, body, meta, ) if err != nil { return 0, "", fmt.Errorf("insert message: %w", err) @@ -396,13 +400,13 @@ func (s *Service) SendChannelMessage( nick, command, channel string, body, meta json.RawMessage, ) (int64, string, error) { + noSuchChannel := &IRCError{ + irc.ErrNoSuchChannel, []string{channel}, msgNoSuchChannel, + } + chID, err := s.db.GetChannelByName(ctx, channel) if err != nil { - return 0, "", &IRCError{ - irc.ErrNoSuchChannel, - []string{channel}, - msgNoSuchChannel, - } + return 0, "", noSuchChannel } if command == irc.CmdPrivmsg { @@ -459,9 +463,14 @@ func (s *Service) SendChannelMessage( recipients := excludeSession(memberIDs, sessionID) dbID, uuid, fanErr := s.FanOut( - ctx, command, nick, channel, + ctx, command, nick, channel, 0, chID, nil, body, meta, recipients, ) + // The channel was deleted after the lookup above. + if db.IsForeignKeyConstraintError(fanErr) { + return 0, "", noSuchChannel + } + if fanErr != nil { return 0, "", fanErr } @@ -478,13 +487,13 @@ func (s *Service) SendDirectMessage( nick, command, target string, body, meta json.RawMessage, ) (*DirectMsgResult, error) { + noSuchNick := &IRCError{ + irc.ErrNoSuchNick, []string{target}, "No such nick", + } + targetSID, err := s.db.GetSessionByNick(ctx, target) if err != nil { - return nil, &IRCError{ - irc.ErrNoSuchNick, - []string{target}, - "No such nick", - } + return nil, noSuchNick } away, _ := s.db.GetAway(ctx, targetSID) @@ -495,9 +504,14 @@ func (s *Service) SendDirectMessage( } _, uuid, fanErr := s.FanOut( - ctx, command, nick, target, + ctx, command, nick, target, targetSID, 0, nil, body, meta, recipients, ) + // The target's session was deleted after the lookup above. + if db.IsForeignKeyConstraintError(fanErr) { + return nil, noSuchNick + } + if fanErr != nil { return nil, fanErr } @@ -561,7 +575,7 @@ func (s *Service) JoinChannel( body, _ := json.Marshal([]string{channel}) //nolint:errchkjson _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast - ctx, irc.CmdJoin, nick, channel, + ctx, irc.CmdJoin, nick, channel, 0, chID, nil, body, nil, memberIDs, ) @@ -604,7 +618,7 @@ func (s *Service) PartChannel( body, _ := json.Marshal([]string{reason}) //nolint:errchkjson _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast - ctx, irc.CmdPart, nick, channel, + ctx, irc.CmdPart, nick, channel, 0, chID, nil, body, nil, recipients, ) @@ -666,7 +680,7 @@ func (s *Service) SetTopic( body, _ := json.Marshal([]string{topic}) //nolint:errchkjson _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast - ctx, irc.CmdTopic, nick, channel, + ctx, irc.CmdTopic, nick, channel, 0, chID, nil, body, nil, memberIDs, ) @@ -730,7 +744,7 @@ func (s *Service) KickUser( ) _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast - ctx, irc.CmdKick, nick, channel, + ctx, irc.CmdKick, nick, channel, 0, chID, params, body, nil, memberIDs, ) @@ -886,7 +900,7 @@ func (s *Service) BroadcastQuit( body, _ := json.Marshal([]string{reason}) //nolint:errchkjson _, _, _ = s.FanOut( - ctx, irc.CmdQuit, nick, "", + ctx, irc.CmdQuit, nick, "", 0, 0, nil, body, nil, recipients, ) } @@ -1074,7 +1088,7 @@ func (s *Service) BroadcastMode( body, _ := json.Marshal([]string{modeText}) //nolint:errchkjson _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast - ctx, irc.CmdMode, nick, channel, + ctx, irc.CmdMode, nick, channel, 0, chID, nil, body, nil, memberIDs, ) } @@ -1351,7 +1365,7 @@ func (s *Service) broadcastNickChange( notified := make(map[int64]bool) dbID, _, insErr := s.db.InsertMessage( - ctx, irc.CmdNick, oldNick, "", + ctx, irc.CmdNick, oldNick, "", 0, 0, nil, body, nil, ) if insErr != nil { diff --git a/internal/service/service_test.go b/internal/service/service_test.go index e619054..5081be3 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -164,7 +164,7 @@ func TestFanOut(t *testing.T) { body, _ := json.Marshal([]string{"hello"}) //nolint:errchkjson dbID, uuid, err := env.svc.FanOut( - ctx, irc.CmdPrivmsg, "alice", "#test", + ctx, irc.CmdPrivmsg, "alice", "#test", 0, 0, nil, body, nil, []int64{sid1, sid2}, ) @@ -339,6 +339,78 @@ func TestBroadcastQuit(t *testing.T) { } } +// TestSendDirectMessageTargetQuits checks that a DM whose +// target quits after its nick is looked up gets the same +// error as a DM to an unknown nick. The trigger deletes the +// target's session just before the DM is stored. +func TestSendDirectMessageTargetQuits(t *testing.T) { + env := newTestEnv(t) + ctx := t.Context() + + sid := createSession(ctx, t, env.db, "alice") + createSession(ctx, t, env.db, "bob") + + _, err := env.db.GetDB().ExecContext(ctx, + `CREATE TRIGGER delete_recipient + BEFORE INSERT ON messages + BEGIN + DELETE FROM sessions WHERE id = NEW.to_session_id; + END`) + if err != nil { + t.Fatalf("create trigger: %v", err) + } + + body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson + + _, err = env.svc.SendDirectMessage( + ctx, sid, "alice", irc.CmdPrivmsg, "bob", body, nil, + ) + + var ircErr *service.IRCError + if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrNoSuchNick { + t.Fatalf("want ERR_NOSUCHNICK, got %v", err) + } +} + +// TestSendChannelMessageChannelDeleted checks that a channel +// message whose channel is deleted after it is looked up gets +// the same error as a message to an unknown channel. The +// trigger deletes the channel just before the message is +// stored. +func TestSendChannelMessageChannelDeleted(t *testing.T) { + env := newTestEnv(t) + ctx := t.Context() + + sid := createSession(ctx, t, env.db, "alice") + + _, err := env.svc.JoinChannel(ctx, sid, "alice", "#gone", "") + if err != nil { + t.Fatalf("join alice: %v", err) + } + + _, err = env.db.GetDB().ExecContext(ctx, + `CREATE TRIGGER delete_recipient + BEFORE INSERT ON messages + BEGIN + DELETE FROM channels WHERE id = NEW.to_channel_id; + END`) + if err != nil { + t.Fatalf("create trigger: %v", err) + } + + body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson + + _, _, err = env.svc.SendChannelMessage( + ctx, sid, "alice", irc.CmdPrivmsg, "#gone", body, nil, + ) + + var ircErr *service.IRCError + if !errors.As(err, &ircErr) || + ircErr.Code != irc.ErrNoSuchChannel { + t.Fatalf("want ERR_NOSUCHCHANNEL, got %v", err) + } +} + func TestSendChannelMessage_Moderated(t *testing.T) { env := newTestEnv(t) ctx := t.Context() -- 2.54.0 From e489f3f3d13898ec1ba0f6a9451cc066bac314d0 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 20 Oct 2026 20:31:27 +0200 Subject: [PATCH 22/40] Spend a channel hashcash stamp in one statement (closes #150) checkChannelHashcash looked a stamp up with one statement and recorded it with another, so concurrent requests carrying the same stamp could all pass the lookup before any of them recorded it, and each message was sent. RecordSpentHashcash now reports whether its INSERT OR IGNORE added a row, and the message is refused when it did not. A single statement is atomic in SQLite. IsHashcashSpent had no other caller and is removed. A failed write of the stamp now refuses the message instead of being logged and ignored. Model: opus-5-5 --- internal/db/queries.go | 32 +++------ internal/handlers/api_test.go | 131 ++++++++++++++++++++++++++++++++++ internal/service/service.go | 18 ++--- 3 files changed, 147 insertions(+), 34 deletions(-) diff --git a/internal/db/queries.go b/internal/db/queries.go index be27ff3..655fa04 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -1783,45 +1783,33 @@ func (database *Database) SetChannelHashcashBits( } // RecordSpentHashcash stores a spent hashcash stamp hash -// for replay prevention. +// for replay prevention. It returns false when the hash was +// already stored. The one INSERT is both the check and the +// record, so of several concurrent callers with the same +// hash exactly one gets true. func (database *Database) RecordSpentHashcash( ctx context.Context, stampHash string, -) error { - _, err := database.conn.ExecContext(ctx, +) (bool, error) { + res, err := database.conn.ExecContext(ctx, `INSERT OR IGNORE INTO spent_hashcash (stamp_hash, created_at) VALUES (?, ?)`, stampHash, time.Now()) if err != nil { - return fmt.Errorf( + return false, fmt.Errorf( "record spent hashcash: %w", err, ) } - return nil -} - -// IsHashcashSpent checks whether a hashcash stamp hash -// has already been used. -func (database *Database) IsHashcashSpent( - ctx context.Context, - stampHash string, -) (bool, error) { - var count int - - err := database.conn.QueryRowContext(ctx, - `SELECT COUNT(*) FROM spent_hashcash - WHERE stamp_hash = ?`, - stampHash, - ).Scan(&count) + inserted, err := res.RowsAffected() if err != nil { return false, fmt.Errorf( - "check spent hashcash: %w", err, + "record spent hashcash: %w", err, ) } - return count > 0, nil + return inserted > 0, nil } // PruneSpentHashcash deletes spent hashcash tokens older diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 31861f3..fca94c9 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -3368,6 +3368,137 @@ func TestChannelHashcashRejectReplayedStamp(t *testing.T) { } } +// sendConcurrently posts cmd in the given number of requests +// at once and returns the status field of each response. +func (tserver *testServer) sendConcurrently( + token string, cmd map[string]any, requests int, +) []any { + tserver.t.Helper() + + body, err := json.Marshal(cmd) + if err != nil { + tserver.t.Fatalf("marshal command: %v", err) + } + + // Each request writes only its own entry. + statuses := make([]any, requests) + start := make(chan struct{}) + + var waitGroup sync.WaitGroup + + for idx := range requests { + waitGroup.Add(1) + + go func() { + defer waitGroup.Done() + + <-start + + resp, reqErr := doRequestAuth( + tserver.t, http.MethodPost, + tserver.url(apiMessages), token, + bytes.NewReader(body), + ) + if reqErr != nil { + tserver.t.Error(reqErr) + + return + } + + defer func() { _ = resp.Body.Close() }() + + var result map[string]any + + _ = json.NewDecoder(resp.Body).Decode(&result) + + statuses[idx] = result[statusKey] + }() + } + + close(start) + waitGroup.Wait() + + return statuses +} + +// TestChannelHashcashRejectConcurrentReplay sends one stamp +// in several requests at once and checks that exactly one is +// accepted and the other member receives exactly one copy. +func TestChannelHashcashRejectConcurrentReplay(t *testing.T) { + const ( + channel = "#hcrace" + text = "one stamp" + sender = "hcrace_sender" + requests = 16 + ) + + tserver := newTestServer(t) + senderToken := tserver.createSession(sender) + receiverToken := tserver.createSession("hcrace_receiver") + + tserver.sendCommand(senderToken, map[string]any{ + commandKey: joinCmd, toKey: channel, + }) + tserver.sendCommand(receiverToken, map[string]any{ + commandKey: joinCmd, toKey: channel, + }) + + tserver.sendCommand(senderToken, map[string]any{ + commandKey: modeCmd, + toKey: channel, + bodyKey: []string{"+H", "2"}, + }) + + _, lastID := tserver.pollMessages(receiverToken, 0) + + msgBody, marshalErr := json.Marshal([]string{text}) + if marshalErr != nil { + t.Fatal(marshalErr) + } + + stamp := mintTestChannelHashcash(t, 2, channel, msgBody) + + statuses := tserver.sendConcurrently(senderToken, map[string]any{ + commandKey: privmsgCmd, + toKey: channel, + bodyKey: []string{text}, + metaKey: map[string]any{hashcashKey: stamp}, + }, requests) + + accepted := 0 + + for _, status := range statuses { + if status == "sent" { + accepted++ + } + } + + if accepted != 1 { + t.Fatalf( + "expected 1 accepted message, got %d: %v", + accepted, statuses, + ) + } + + msgs, _ := tserver.pollMessages(receiverToken, lastID) + + delivered := 0 + + for _, msg := range msgs { + if msg[commandKey] == privmsgCmd && + msg["from"] == sender { + delivered++ + } + } + + if delivered != 1 { + t.Fatalf( + "expected 1 delivered copy, got %d: %v", + delivered, msgs, + ) + } +} + func TestChannelHashcashNoRequirementWorks(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession("hcnone_user") diff --git a/internal/service/service.go b/internal/service/service.go index 4a43946..2977aa9 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -1285,8 +1285,9 @@ func (s *Service) requireOper( // checkChannelHashcash returns ERR_CANNOTSENDTOCHAN unless // the channel has no +H, or meta carries a hashcash stamp // for this channel and body with enough bits that has not -// been used before. A stamp that passes is recorded as -// spent. +// been used before. Recording the stamp as spent is the +// check that it is unused, so a stamp sent in several +// concurrent requests passes in only one of them. func (s *Service) checkChannelHashcash( ctx context.Context, chID int64, @@ -1324,12 +1325,12 @@ func (s *Service) checkChannelHashcash( stampKey := hashcash.StampHash(stamp) - spent, err := s.db.IsHashcashSpent(ctx, stampKey) + recorded, err := s.db.RecordSpentHashcash(ctx, stampKey) if err != nil { - return fmt.Errorf("check spent hashcash: %w", err) + return fmt.Errorf("record spent hashcash: %w", err) } - if spent { + if !recorded { return &IRCError{ irc.ErrCannotSendToChan, []string{channel}, @@ -1337,13 +1338,6 @@ func (s *Service) checkChannelHashcash( } } - err = s.db.RecordSpentHashcash(ctx, stampKey) - if err != nil { - s.log.Error( - "record spent hashcash", "error", err, - ) - } - return nil } -- 2.54.0 From e3d01454b3d87fa8720d457af075a2a098c038ab Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 20 Oct 2026 20:53:00 +0200 Subject: [PATCH 23/40] PING a quiet IRC listener client before closing it (closes #155) serve closed a connection after five minutes with nothing read and never sent a PING, so a quiet registered client lost its session. It now sends PING : after pingInterval with nothing read, and closes the connection when nothing is read within pongDeadline after that. Every line read starts both waits again. readTimeout is removed. Unregistered connections are sent the PING too, so a PONG before registration is now ignored instead of answered with ERR_NOTREGISTERED. Model: opus-5-5 --- README.md | 3 + internal/ircserver/conn.go | 25 +++- internal/ircserver/export_test.go | 23 +++- internal/ircserver/server.go | 11 +- internal/ircserver/server_test.go | 192 +++++++++++++++++++++++++++++- 5 files changed, 247 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8fc3ff5..6f983ab 100644 --- a/README.md +++ b/README.md @@ -2677,6 +2677,9 @@ IRC_LISTEN_ADDR: "" An optional `PASS` before registration sets the session password (minimum 8 characters). Registering creates a session without a hashcash stamp; closing the connection ends the session, as `QUIT` does. +- **PING**: a connection that sends nothing for 90 seconds is sent a `PING`, and + is closed if it sends nothing in the 30 seconds after that; any line, such as + the client's `PONG`, starts both waits again. - **CAP negotiation**: `CAP LS` gets an empty capability list and `CAP END` is ignored, for compatibility with modern clients. - **Channel prefixes**: Channels must start with `#`. `JOIN` prepends it if diff --git a/internal/ircserver/conn.go b/internal/ircserver/conn.go index 8fc9224..bba99ca 100644 --- a/internal/ircserver/conn.go +++ b/internal/ircserver/conn.go @@ -20,7 +20,6 @@ import ( const ( maxLineLen = 512 - readTimeout = 5 * time.Minute writeTimeout = 30 * time.Second killWriteWindow = 2 * time.Second dnsTimeout = 3 * time.Second @@ -214,17 +213,31 @@ func resolveHost(ctx context.Context, addr string) string { // serve is the main loop for a single IRC client connection. // Cancelling ctx when it returns stops the relay goroutine. -func (c *Conn) serve(ctx context.Context) { +// When nothing has been read for interval the client is sent +// a PING, and when nothing is read within deadline after that +// the connection is closed. +func (c *Conn) serve( + ctx context.Context, + interval, deadline time.Duration, +) { ctx, cancel := context.WithCancel(ctx) defer cancel() defer c.cleanup(ctx) + ping := time.AfterFunc(interval, func() { + c.send("PING :" + c.serverSfx) + }) + defer ping.Stop() + scanner := bufio.NewScanner(c.conn) scanner.Buffer(make([]byte, maxLineLen), maxLineLen) for { + // Every line read starts both waits again. + ping.Reset(interval) + _ = c.conn.SetReadDeadline( - time.Now().Add(readTimeout), + time.Now().Add(interval + deadline), ) if !scanner.Scan() { @@ -366,7 +379,7 @@ func (c *Conn) handleMessage( msg *Message, ) { // Before registration, only NICK, USER, PASS, PING, - // QUIT, and CAP are accepted. + // PONG, QUIT, and CAP are accepted. if !c.registered { c.handlePreRegistration(ctx, msg) @@ -452,6 +465,10 @@ func (c *Conn) handlePreRegistration( case irc.CmdPing: c.handlePing(msg) + return + case irc.CmdPong: + // The answer to serve's PING, which unregistered + // clients are sent too. return case irc.CmdQuit: c.handleQuit(msg) diff --git a/internal/ircserver/export_test.go b/internal/ircserver/export_test.go index 60d039b..db02999 100644 --- a/internal/ircserver/export_test.go +++ b/internal/ircserver/export_test.go @@ -13,13 +13,16 @@ import ( "sneak.berlin/go/neoirc/internal/service" ) -// NewTestServer creates a Server suitable for testing. +// NewTestServer creates a Server suitable for testing, whose +// connections use interval and deadline in place of +// pingInterval and pongDeadline. // The caller must call Stop() when finished. func NewTestServer( log *slog.Logger, cfg *config.Config, database *db.Database, brk *broker.Broker, + interval, deadline time.Duration, ) *Server { globs := &globals.Globals{ Appname: "neoirc", @@ -38,6 +41,9 @@ func NewTestServer( brk: brk, svc: svc, conns: make(map[*Conn]struct{}), + + pingInterval: interval, + pongDeadline: deadline, } } @@ -56,6 +62,13 @@ func (s *Server) Listener() net.Listener { return s.listener } +// 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 @@ -64,6 +77,14 @@ const KillWriteWindow = killWriteWindow // 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 + // NewTestConn returns a Conn for nick on tcpConn, without a // server, database or service behind it. func NewTestConn( diff --git a/internal/ircserver/server.go b/internal/ircserver/server.go index e3b6d2e..8d5ce86 100644 --- a/internal/ircserver/server.go +++ b/internal/ircserver/server.go @@ -6,6 +6,7 @@ import ( "log/slog" "net" "sync" + "time" "go.uber.org/fx" "sneak.berlin/go/neoirc/internal/broker" @@ -38,6 +39,11 @@ type Server struct { mu sync.Mutex conns map[*Conn]struct{} cancel context.CancelFunc + + // pingInterval and pongDeadline are the constants of the + // same names, except in tests, which shorten them. + pingInterval time.Duration + pongDeadline time.Duration } // New creates a new IRC Server and registers its lifecycle @@ -57,6 +63,9 @@ func New( listener: nil, cancel: nil, mu: sync.Mutex{}, + + pingInterval: pingInterval, + pongDeadline: pongDeadline, } listenAddr := params.Config.IRCListenAddr @@ -153,7 +162,7 @@ func (s *Server) acceptLoop(ctx context.Context) { s.mu.Unlock() }() - client.serve(ctx) + client.serve(ctx, s.pingInterval, s.pongDeadline) }() } } diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index 6aba3a8..e7f92d4 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -13,10 +13,13 @@ import ( "testing" "time" + "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/ircserver" + "sneak.berlin/go/neoirc/internal/logger" _ "modernc.org/sqlite" ) @@ -70,6 +73,22 @@ func newTestEnvWithConfig( ) *testEnv { 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() + // A random name, so that no other test environment, not // even an earlier run of the same test, can share it. dsn := fmt.Sprintf( @@ -126,7 +145,9 @@ func newTestEnvWithConfig( &slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct )) - srv := ircserver.NewTestServer(log, cfg, database, brk) + srv := ircserver.NewTestServer( + log, cfg, database, brk, interval, deadline, + ) err = srv.Start(addr) if err != nil { @@ -174,6 +195,34 @@ 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. type testClient struct { t *testing.T @@ -272,6 +321,24 @@ func (tc *testClient) readUntilClosed() []string { 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 // given substring. func assertContains( @@ -388,6 +455,129 @@ func TestPingPong(t *testing.T) { 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. -- 2.54.0 From 292f1eb7a9d073b1bc60cf5d8ac040b2165515e2 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 20 Oct 2026 21:31:26 +0200 Subject: [PATCH 24/40] Serve the index page for an empty URL path instead of panicking (closes #149) setupSPA took the file name from request.URL.Path[1:], which panics when the path is empty, as it is for an absolute-form request line with no path ("GET http://host HTTP/1.1"). It now trims a leading "/" instead; an empty name fails to read, so the request gets the index page, as "/" already does. The new test in internal/server sends such a request and expects 200 and the index page. export_test.go gives it a Server whose router has only the web client route. Model: opus-5-5 --- internal/server/export_test.go | 12 ++++++++++ internal/server/routes.go | 5 ++++- internal/server/routes_test.go | 41 ++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 internal/server/export_test.go create mode 100644 internal/server/routes_test.go diff --git a/internal/server/export_test.go b/internal/server/export_test.go new file mode 100644 index 0000000..656b378 --- /dev/null +++ b/internal/server/export_test.go @@ -0,0 +1,12 @@ +package server + +import "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 +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 1aa741b..f428c92 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -3,6 +3,7 @@ package server import ( "io/fs" "net/http" + "strings" "time" "sneak.berlin/go/neoirc/web" @@ -137,8 +138,10 @@ func (srv *Server) setupSPA() { return } + // The path is empty, not "/", for a request line such + // as "GET http://host HTTP/1.1". fileData, readErr := readFS.ReadFile( - request.URL.Path[1:], + strings.TrimPrefix(request.URL.Path, "/"), ) if readErr != nil || len(fileData) == 0 { indexHTML, indexErr := readFS.ReadFile( diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go new file mode 100644 index 0000000..a98ff1b --- /dev/null +++ b/internal/server/routes_test.go @@ -0,0 +1,41 @@ +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") + } +} -- 2.54.0 From 108e381428202f1802cc50140e5f7638f86cab46 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 20 Oct 2026 22:48:25 +0200 Subject: [PATCH 25/40] Run JOIN, PART and KICK as one transaction each (closes #153) JoinChannel made the channel, counted its members, ran the join checks and inserted the member as separate statements, so two requests could each decide on a count the other was about to change: +l was exceeded, two first joiners both became operator, and a JOIN racing the last PART failed its foreign key and answered 500. db.CreateOrJoinChannel now does all of that, and uses up the invite, in one transaction; the join checks moved into it, and the service turns its refusals into the same IRC errors as before. PART and KICK remove the member and delete an empty channel in one transaction (db.PartChannelAndDeleteIfEmpty). The db functions this leaves with no caller are removed. Model: opus-5-5 --- internal/db/queries.go | 271 +++++++++++++++++---- internal/db/queries_test.go | 11 - internal/service/service.go | 92 ++----- internal/service/service_test.go | 397 +++++++++++++++++++++++++++++++ 4 files changed, 645 insertions(+), 126 deletions(-) diff --git a/internal/db/queries.go b/internal/db/queries.go index 655fa04..3db2e63 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -462,46 +462,207 @@ func (database *Database) JoinChannel( return nil } -// JoinChannelAsOperator adds a session to a channel with -// operator status. Used when a user creates a new channel. -func (database *Database) JoinChannelAsOperator( +// Errors that CreateOrJoinChannel refuses a join with. +var ( + ErrBannedFromChannel = errors.New("banned from channel") + ErrInviteOnlyChannel = errors.New("channel is invite-only") + ErrBadChannelKey = errors.New("wrong channel key") + ErrChannelIsFull = errors.New("channel is full") +) + +// CreateOrJoinChannel adds a session to the channel called +// name, creating the channel if it does not exist. The +// session is made operator if the channel has no members; +// otherwise checkJoinRestrictions may refuse it. A join +// uses up the session's invite to the channel. It returns +// the channel ID and whether the session was made operator. +// All of it is one transaction, and the database has one +// connection, so every other statement waits until the +// join is decided. +func (database *Database) CreateOrJoinChannel( ctx context.Context, - channelID, sessionID int64, -) error { - _, err := database.conn.ExecContext(ctx, + name string, + sessionID int64, + suppliedKey string, +) (int64, bool, error) { + transaction, err := database.conn.BeginTx(ctx, nil) + if err != nil { + return 0, false, fmt.Errorf("begin join: %w", err) + } + + defer func() { _ = transaction.Rollback() }() + + now := time.Now() + + _, err = transaction.ExecContext(ctx, + `INSERT OR IGNORE INTO channels + (name, created_at, updated_at) + VALUES (?, ?, ?)`, + name, now, now) + if err != nil { + return 0, false, fmt.Errorf("create channel: %w", err) + } + + var channelID, memberCount int64 + + err = transaction.QueryRowContext(ctx, + `SELECT id, + (SELECT COUNT(*) FROM channel_members + WHERE channel_id = channels.id) + FROM channels WHERE name = ?`, + name, + ).Scan(&channelID, &memberCount) + if err != nil { + return 0, false, fmt.Errorf("get channel: %w", err) + } + + isOperator := memberCount == 0 + if !isOperator { + err = checkJoinRestrictions( + ctx, transaction, channelID, sessionID, + suppliedKey, memberCount, + ) + if err != nil { + return 0, false, err + } + } + + _, err = transaction.ExecContext(ctx, `INSERT OR IGNORE INTO channel_members (channel_id, session_id, is_operator, joined_at) - VALUES (?, ?, 1, ?)`, - channelID, sessionID, time.Now()) + VALUES (?, ?, ?, ?)`, + channelID, sessionID, isOperator, now) if err != nil { - return fmt.Errorf( - "join channel as operator: %w", err, - ) + return 0, false, fmt.Errorf("join channel: %w", err) + } + + _, err = transaction.ExecContext(ctx, + `DELETE FROM channel_invites + WHERE channel_id = ? AND session_id = ?`, + channelID, sessionID) + if err != nil { + return 0, false, fmt.Errorf("clear invite: %w", err) + } + + err = transaction.Commit() + if err != nil { + return 0, false, fmt.Errorf("commit join: %w", err) + } + + return channelID, isOperator, nil +} + +// checkJoinRestrictions returns the error that refuses a +// session's join of a channel with memberCount members: +// ErrBannedFromChannel when a ban matches its hostmask, +// ErrInviteOnlyChannel when the channel is +i and the +// session has no invite, ErrBadChannelKey when suppliedKey +// is not the +k key, or ErrChannelIsFull when the channel +// has at least its +l number of members. It returns nil +// when none applies. +func checkJoinRestrictions( + ctx context.Context, + transaction *sql.Tx, + channelID, sessionID int64, + suppliedKey string, + memberCount int64, +) error { + banned, err := isSessionBannedInTx( + ctx, transaction, channelID, sessionID, + ) + if err != nil { + return err + } + + if banned { + return ErrBannedFromChannel + } + + var ( + inviteOnly, invited bool + key string + userLimit int64 + ) + + err = transaction.QueryRowContext(ctx, + `SELECT is_invite_only, channel_key, user_limit, + EXISTS (SELECT 1 FROM channel_invites + WHERE channel_id = channels.id + AND session_id = ?) + FROM channels WHERE id = ?`, + sessionID, channelID, + ).Scan(&inviteOnly, &key, &userLimit, &invited) + if err != nil { + return fmt.Errorf("get channel modes: %w", err) + } + + switch { + case inviteOnly && !invited: + return ErrInviteOnlyChannel + case key != "" && suppliedKey != key: + return ErrBadChannelKey + case userLimit > 0 && memberCount >= userLimit: + return ErrChannelIsFull } return nil } -// CountChannelMembers returns the number of members in -// a channel. -func (database *Database) CountChannelMembers( +// isSessionBannedInTx is IsSessionBanned, reading through +// transaction. +func isSessionBannedInTx( ctx context.Context, - channelID int64, -) (int64, error) { - var count int64 + transaction *sql.Tx, + channelID, sessionID int64, +) (bool, error) { + var nick, username, hostname string - err := database.conn.QueryRowContext(ctx, - `SELECT COUNT(*) FROM channel_members - WHERE channel_id = ?`, - channelID, - ).Scan(&count) + err := transaction.QueryRowContext(ctx, + `SELECT nick, username, hostname + FROM sessions WHERE id = ?`, + sessionID, + ).Scan(&nick, &username, &hostname) if err != nil { - return 0, fmt.Errorf( - "count channel members: %w", err, + return false, fmt.Errorf( + "get session hostmask: %w", err, ) } - return count, nil + hostmask := FormatHostmask(nick, username, hostname) + + rows, err := transaction.QueryContext(ctx, + `SELECT mask FROM channel_bans + WHERE channel_id = ?`, + channelID) + if err != nil { + return false, fmt.Errorf("list channel bans: %w", err) + } + + defer func() { _ = rows.Close() }() + + for rows.Next() { + var mask string + + err = rows.Scan(&mask) + if err != nil { + return false, fmt.Errorf( + "scan channel ban: %w", err, + ) + } + + if MatchBanMask(mask, hostmask) { + return true, nil + } + } + + err = rows.Err() + if err != nil { + return false, fmt.Errorf( + "iterate channel bans: %w", err, + ) + } + + return false, nil } // IsChannelOperator checks if a session has operator @@ -729,6 +890,49 @@ func (database *Database) DeleteChannelIfEmpty( return nil } +// PartChannelAndDeleteIfEmpty removes a session from a +// channel and deletes the channel if that left it with no +// members, in one transaction, so that no JOIN lands +// between the two. +func (database *Database) PartChannelAndDeleteIfEmpty( + ctx context.Context, + channelID, sessionID int64, +) error { + transaction, err := database.conn.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin part: %w", err) + } + + defer func() { _ = transaction.Rollback() }() + + _, err = transaction.ExecContext(ctx, + `DELETE FROM channel_members + WHERE channel_id = ? AND session_id = ?`, + channelID, sessionID) + if err != nil { + return fmt.Errorf("part channel: %w", err) + } + + _, err = transaction.ExecContext(ctx, + `DELETE FROM channels WHERE id = ? + AND NOT EXISTS + (SELECT 1 FROM channel_members + WHERE channel_id = ?)`, + channelID, channelID) + if err != nil { + return fmt.Errorf( + "delete channel if empty: %w", err, + ) + } + + err = transaction.Commit() + if err != nil { + return fmt.Errorf("commit part: %w", err) + } + + return nil +} + // scanChannels scans rows into a ChannelInfo slice. func scanChannels( rows *sql.Rows, @@ -2100,23 +2304,6 @@ func (database *Database) HasChannelInvite( return count > 0, nil } -// ClearChannelInvite removes a session's invite to a -// channel (called after successful JOIN). -func (database *Database) ClearChannelInvite( - ctx context.Context, - channelID, sessionID int64, -) error { - _, err := database.conn.ExecContext(ctx, - `DELETE FROM channel_invites - WHERE channel_id = ? AND session_id = ?`, - channelID, sessionID) - if err != nil { - return fmt.Errorf("clear invite: %w", err) - } - - return nil -} - // --- Tier 2: Secret (+s) --- // IsChannelSecret checks if a channel has +s mode. diff --git a/internal/db/queries_test.go b/internal/db/queries_test.go index 746f3d7..99fad5f 100644 --- a/internal/db/queries_test.go +++ b/internal/db/queries_test.go @@ -1342,17 +1342,6 @@ func TestChannelInviteCRUD(t *testing.T) { if !has { 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) { diff --git a/internal/service/service.go b/internal/service/service.go index 2977aa9..dabe74f 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -536,41 +536,13 @@ func (s *Service) JoinChannel( } } - chID, err := s.db.GetOrCreateChannel(ctx, channel) - if err != nil { - return nil, fmt.Errorf("get/create channel: %w", err) - } - - memberCount, countErr := s.db.CountChannelMembers( - ctx, chID, + chID, isCreator, err := s.db.CreateOrJoinChannel( + ctx, channel, sessionID, suppliedKey, ) - isCreator := countErr == nil && memberCount == 0 - - if !isCreator { - joinErr := checkJoinRestrictions( - ctx, s.db, chID, sessionID, - channel, suppliedKey, memberCount, - ) - if joinErr != nil { - return nil, joinErr - } - } - - if isCreator { - err = s.db.JoinChannelAsOperator( - ctx, chID, sessionID, - ) - } else { - err = s.db.JoinChannel(ctx, chID, sessionID) - } - if err != nil { - return nil, fmt.Errorf("join channel: %w", err) + return nil, joinError(channel, err) } - // Clear invite after successful join. - _ = s.db.ClearChannelInvite(ctx, chID, sessionID) - memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID) body, _ := json.Marshal([]string{channel}) //nolint:errchkjson @@ -622,8 +594,7 @@ func (s *Service) PartChannel( nil, body, nil, recipients, ) - s.db.PartChannel(ctx, chID, sessionID) //nolint:errcheck,gosec - s.db.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec + s.db.PartChannelAndDeleteIfEmpty(ctx, chID, sessionID) //nolint:errcheck,gosec return nil } @@ -748,8 +719,7 @@ func (s *Service) KickUser( params, body, nil, memberIDs, ) - s.db.PartChannel(ctx, chID, targetSID) //nolint:errcheck,gosec - s.db.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec + s.db.PartChannelAndDeleteIfEmpty(ctx, chID, targetSID) //nolint:errcheck,gosec return nil } @@ -1392,54 +1362,30 @@ func (s *Service) broadcastNickChange( } } -// checkJoinRestrictions validates Tier 2 join conditions: -// bans, invite-only, channel key, and user limit. -func checkJoinRestrictions( - ctx context.Context, - database *db.Database, - chID, sessionID int64, - channel, suppliedKey string, - memberCount int64, -) error { - isBanned, banErr := database.IsSessionBanned( - ctx, chID, sessionID, - ) - if banErr == nil && isBanned { +// joinError returns the IRC error for a JOIN of channel +// that db.CreateOrJoinChannel refused with err, or err +// wrapped if it is not a refusal. +func joinError(channel string, err error) error { + switch { + case errors.Is(err, db.ErrBannedFromChannel): return &IRCError{ Code: irc.ErrBannedFromChan, Params: []string{channel}, Message: "Cannot join channel (+b)", } - } - - isInviteOnly, ioErr := database.IsChannelInviteOnly( - ctx, chID, - ) - if ioErr == nil && isInviteOnly { - hasInvite, invErr := database.HasChannelInvite( - ctx, chID, sessionID, - ) - if invErr != nil || !hasInvite { - return &IRCError{ - Code: irc.ErrInviteOnlyChan, - Params: []string{channel}, - Message: "Cannot join channel (+i)", - } + case errors.Is(err, db.ErrInviteOnlyChannel): + return &IRCError{ + Code: irc.ErrInviteOnlyChan, + Params: []string{channel}, + Message: "Cannot join channel (+i)", } - } - - key, keyErr := database.GetChannelKey(ctx, chID) - if keyErr == nil && key != "" && suppliedKey != key { + case errors.Is(err, db.ErrBadChannelKey): return &IRCError{ Code: irc.ErrBadChannelKey, Params: []string{channel}, Message: "Cannot join channel (+k)", } - } - - limit, limErr := database.GetChannelUserLimit(ctx, chID) - if limErr == nil && limit > 0 && - memberCount >= int64(limit) { + case errors.Is(err, db.ErrChannelIsFull): return &IRCError{ Code: irc.ErrChannelIsFull, Params: []string{channel}, @@ -1447,7 +1393,7 @@ func checkJoinRestrictions( } } - return nil + return fmt.Errorf("join channel: %w", err) } // extractHashcashFromMeta parses the meta JSON and diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 5081be3..6170f54 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -13,6 +13,7 @@ import ( "os" "slices" "strings" + "sync" "testing" "time" @@ -255,6 +256,402 @@ func TestPartChannel(t *testing.T) { } } +// concurrentJoinTrials is how many times each test of +// concurrent JOINs repeats its race. Before a JOIN ran as +// one transaction, ten trials lost each race every time. +const concurrentJoinTrials = 10 + +// runConcurrently runs each of funcs in its own goroutine, +// releasing them all at once, and returns their errors in +// the order of funcs. +func runConcurrently(funcs ...func() error) []error { + start := make(chan struct{}) + errs := make([]error, len(funcs)) + + var wg sync.WaitGroup + + for i, run := range funcs { + wg.Add(1) + + go func() { + defer wg.Done() + + <-start + + errs[i] = run() + }() + } + + close(start) + wg.Wait() + + return errs +} + +// joinConcurrently creates a session for each nick and has +// them all JOIN channel at once. It returns the result and +// error of each JOIN, in the order of nicks. +func joinConcurrently( + ctx context.Context, + t *testing.T, + env *testEnv, + channel string, + nicks ...string, +) ([]*service.JoinResult, []error) { + t.Helper() + + results := make([]*service.JoinResult, len(nicks)) + joins := make([]func() error, len(nicks)) + + for i, nick := range nicks { + sessionID := createSession(ctx, t, env.db, nick) + + joins[i] = func() error { + result, err := env.svc.JoinChannel( + ctx, sessionID, nick, channel, "", + ) + results[i] = result + + return err + } + } + + return results, runConcurrently(joins...) +} + +// TestConcurrentJoinsRespectUserLimit checks that of two +// concurrent JOINs of a +l 2 channel with one member, one +// gets in and the other gets ERR_CHANNELISFULL. +func TestConcurrentJoinsRespectUserLimit(t *testing.T) { + const userLimit = 2 + + env := newTestEnv(t) + ctx := t.Context() + + for trial := range concurrentJoinTrials { + channel := fmt.Sprintf("#full%d", trial) + opNick := fmt.Sprintf("op%d", trial) + opSID := createSession(ctx, t, env.db, opNick) + + created, err := env.svc.JoinChannel( + ctx, opSID, opNick, channel, "", + ) + if err != nil { + t.Fatalf("join %s: %v", opNick, err) + } + + err = env.db.SetChannelUserLimit( + ctx, created.ChannelID, userLimit, + ) + if err != nil { + t.Fatalf("set +l: %v", err) + } + + _, errs := joinConcurrently( + ctx, t, env, channel, + fmt.Sprintf("a%d", trial), fmt.Sprintf("b%d", trial), + ) + + refused := 0 + + for _, joinErr := range errs { + var ircErr *service.IRCError + + switch { + case joinErr == nil: + case errors.As(joinErr, &ircErr) && + ircErr.Code == irc.ErrChannelIsFull: + refused++ + default: + t.Errorf("trial %d: JOIN: %v", trial, joinErr) + } + } + + members, err := env.db.ChannelMembers(ctx, created.ChannelID) + if err != nil { + t.Fatalf("channel members: %v", err) + } + + if len(members) != userLimit || refused != 1 { + t.Errorf( + "trial %d: %d members and %d refused, want %d and 1", + trial, len(members), refused, userLimit, + ) + } + } +} + +// TestConcurrentFirstJoinsMakeOneOperator checks that of +// two concurrent JOINs that create a channel, exactly one +// makes its session the operator. +func TestConcurrentFirstJoinsMakeOneOperator(t *testing.T) { + env := newTestEnv(t) + ctx := t.Context() + + for trial := range concurrentJoinTrials { + results, errs := joinConcurrently( + ctx, t, env, fmt.Sprintf("#new%d", trial), + fmt.Sprintf("first%d", trial), + fmt.Sprintf("second%d", trial), + ) + + creators := 0 + + for i, err := range errs { + if err != nil { + t.Fatalf("trial %d: JOIN: %v", trial, err) + } + + if results[i].IsCreator { + creators++ + } + } + + members, err := env.db.ChannelMembers( + ctx, results[0].ChannelID, + ) + if err != nil { + t.Fatalf("channel members: %v", err) + } + + operators := 0 + + for _, member := range members { + if member.IsOperator { + operators++ + } + } + + if creators != 1 || operators != 1 { + t.Errorf( + "trial %d: %d creators and %d operators, want 1 each", + trial, creators, operators, + ) + } + } +} + +// TestJoinConcurrentWithLastPart checks that a JOIN +// concurrent with the PART of a channel's only member +// either joins a channel that still exists or gets +// ERR_NOSUCHCHANNEL, and never fails otherwise. +func TestJoinConcurrentWithLastPart(t *testing.T) { + env := newTestEnv(t) + ctx := t.Context() + + for trial := range concurrentJoinTrials { + channel := fmt.Sprintf("#last%d", trial) + leaverNick := fmt.Sprintf("leaver%d", trial) + leaverSID := createSession(ctx, t, env.db, leaverNick) + joinerNick := fmt.Sprintf("joiner%d", trial) + joinerSID := createSession(ctx, t, env.db, joinerNick) + + _, err := env.svc.JoinChannel( + ctx, leaverSID, leaverNick, channel, "", + ) + if err != nil { + t.Fatalf("join %s: %v", leaverNick, err) + } + + errs := runConcurrently( + func() error { + return env.svc.PartChannel( + ctx, leaverSID, leaverNick, channel, "", + ) + }, + func() error { + _, joinErr := env.svc.JoinChannel( + ctx, joinerSID, joinerNick, channel, "", + ) + + return joinErr + }, + ) + if errs[0] != nil { + t.Fatalf("trial %d: PART: %v", trial, errs[0]) + } + + var ircErr *service.IRCError + + switch { + case errs[1] == nil: + chID, lookupErr := env.db.GetChannelByName(ctx, channel) + isMember, _ := env.db.IsChannelMember(ctx, chID, joinerSID) + + if lookupErr != nil || !isMember { + t.Errorf( + "trial %d: JOIN succeeded, but %s is not on %s", + trial, joinerNick, channel, + ) + } + case errors.As(errs[1], &ircErr) && + ircErr.Code == irc.ErrNoSuchChannel: + default: + t.Errorf( + "trial %d: JOIN got %v, want success or 403", + trial, errs[1], + ) + } + } +} + +// TestJoinUsesUpInvite checks that the JOIN an invite let +// into a +i channel uses the invite up, so that a JOIN +// after leaving gets ERR_INVITEONLYCHAN. +func TestJoinUsesUpInvite(t *testing.T) { + const channel = "#club" + + env := newTestEnv(t) + ctx := t.Context() + + opSID := createSession(ctx, t, env.db, "op") + guestSID := createSession(ctx, t, env.db, "guest") + + created, err := env.svc.JoinChannel(ctx, opSID, "op", channel, "") + if err != nil { + t.Fatalf("join op: %v", err) + } + + err = env.db.SetChannelInviteOnly(ctx, created.ChannelID, true) + if err != nil { + t.Fatalf("set +i: %v", err) + } + + _, err = env.svc.InviteUser(ctx, opSID, "op", "guest", channel) + if err != nil { + t.Fatalf("invite guest: %v", err) + } + + _, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "") + if err != nil { + t.Fatalf("invited join: %v", err) + } + + err = env.svc.PartChannel(ctx, guestSID, "guest", channel, "") + if err != nil { + t.Fatalf("part guest: %v", err) + } + + _, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "") + + var ircErr *service.IRCError + if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrInviteOnlyChan { + t.Fatalf("second join: want ERR_INVITEONLYCHAN, got %v", err) + } +} + +// TestJoinRefusedByHostmaskBan checks that a ban naming a +// session's nick, username and hostname refuses its JOIN +// with ERR_BANNEDFROMCHAN. +func TestJoinRefusedByHostmaskBan(t *testing.T) { + const channel = "#club" + + env := newTestEnv(t) + ctx := t.Context() + + opSID := createSession(ctx, t, env.db, "op") + + created, err := env.svc.JoinChannel(ctx, opSID, "op", channel, "") + if err != nil { + t.Fatalf("join op: %v", err) + } + + err = env.db.AddChannelBan( + ctx, created.ChannelID, "guest!user@host.example", "op", + ) + if err != nil { + t.Fatalf("add ban: %v", err) + } + + guestSID, _, _, err := env.db.CreateSession( + ctx, "guest", "user", "host.example", "127.0.0.1", + ) + if err != nil { + t.Fatalf("create session guest: %v", err) + } + + _, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "") + + var ircErr *service.IRCError + if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrBannedFromChan { + t.Fatalf("want ERR_BANNEDFROMCHAN, got %v", err) + } +} + +// TestPartDeletesChannelOnlyWhenEmpty checks that a PART +// keeps a channel other members are still on and deletes +// the channel when its last member parts. +func TestPartDeletesChannelOnlyWhenEmpty(t *testing.T) { + const channel = "#room" + + env := newTestEnv(t) + ctx := t.Context() + + aliceSID := createSession(ctx, t, env.db, "alice") + bobSID := createSession(ctx, t, env.db, "bob") + + _, err := env.svc.JoinChannel(ctx, aliceSID, "alice", channel, "") + if err != nil { + t.Fatalf("join alice: %v", err) + } + + _, err = env.svc.JoinChannel(ctx, bobSID, "bob", channel, "") + if err != nil { + t.Fatalf("join bob: %v", err) + } + + err = env.svc.PartChannel(ctx, aliceSID, "alice", channel, "") + if err != nil { + t.Fatalf("part alice: %v", err) + } + + chID, err := env.db.GetChannelByName(ctx, channel) + if err != nil { + t.Fatalf("channel gone while bob is on it: %v", err) + } + + isMember, _ := env.db.IsChannelMember(ctx, chID, bobSID) + if !isMember { + t.Fatal("bob is not on the channel after alice parted") + } + + err = env.svc.PartChannel(ctx, bobSID, "bob", channel, "") + if err != nil { + t.Fatalf("part bob: %v", err) + } + + _, err = env.db.GetChannelByName(ctx, channel) + if err == nil { + t.Fatal("channel still exists after its last member parted") + } +} + +// TestKickOfLastMemberDeletesChannel checks that a KICK +// that leaves a channel with no members deletes it. +func TestKickOfLastMemberDeletesChannel(t *testing.T) { + const channel = "#room" + + env := newTestEnv(t) + ctx := t.Context() + + sid := createSession(ctx, t, env.db, "alice") + + _, err := env.svc.JoinChannel(ctx, sid, "alice", channel, "") + if err != nil { + t.Fatalf("join alice: %v", err) + } + + err = env.svc.KickUser(ctx, sid, "alice", channel, "alice", "") + if err != nil { + t.Fatalf("kick alice: %v", err) + } + + _, err = env.db.GetChannelByName(ctx, channel) + if err == nil { + t.Fatal("channel still exists after its last member was kicked") + } +} + func TestSendChannelMessage(t *testing.T) { env := newTestEnv(t) ctx := t.Context() -- 2.54.0 From 497514d3df027afa16387c697b25bad71d0e5327 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 20 Oct 2026 23:48:30 +0200 Subject: [PATCH 26/40] Wait for IRC listener cleanup and HTTP shutdown on stop (closes #159) Stopping the server returned before the IRC listener's connections had run their cleanup, and the database closed under them, so their sessions, clients and channel memberships stayed and their nicks stayed taken after a restart. The IRC listener's stop now waits up to five seconds for every connection's cleanup, which now runs on a context that stopping does not cancel. The HTTP server's stop hook now runs Shutdown itself and waits for it; httpServer is set before serving starts, and the server's own signal watcher, which raced fx's stop hooks, is gone. Model: opus-5-5 --- internal/ircserver/conn.go | 4 +- internal/ircserver/export_test.go | 4 + internal/ircserver/server.go | 44 +++++- internal/ircserver/server_test.go | 115 ++++++++++++++ internal/server/server.go | 67 +++----- internal/server/server_test.go | 247 ++++++++++++++++++++++++++++++ 6 files changed, 433 insertions(+), 48 deletions(-) create mode 100644 internal/server/server_test.go diff --git a/internal/ircserver/conn.go b/internal/ircserver/conn.go index bba99ca..e11d09b 100644 --- a/internal/ircserver/conn.go +++ b/internal/ircserver/conn.go @@ -222,7 +222,9 @@ func (c *Conn) serve( ) { ctx, cancel := context.WithCancel(ctx) defer cancel() - defer c.cleanup(ctx) + // Stopping the server cancels ctx, and the cleanup must + // still end the session then. + defer c.cleanup(context.WithoutCancel(ctx)) ping := time.AfterFunc(interval, func() { c.send("PING :" + c.serverSfx) diff --git a/internal/ircserver/export_test.go b/internal/ircserver/export_test.go index db02999..11f4b8d 100644 --- a/internal/ircserver/export_test.go +++ b/internal/ircserver/export_test.go @@ -85,6 +85,10 @@ const PingInterval = pingInterval // 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( diff --git a/internal/ircserver/server.go b/internal/ircserver/server.go index 8d5ce86..3459860 100644 --- a/internal/ircserver/server.go +++ b/internal/ircserver/server.go @@ -16,6 +16,10 @@ import ( "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 // Server. type Params struct { @@ -40,6 +44,10 @@ type Server struct { conns map[*Conn]struct{} 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 @@ -63,6 +71,7 @@ func New( listener: nil, cancel: nil, mu: sync.Mutex{}, + serving: sync.WaitGroup{}, pingInterval: pingInterval, pongDeadline: pongDeadline, @@ -112,7 +121,10 @@ func (s *Server) start(ctx context.Context, addr string) error { return nil } -// stop shuts down the listener and all connections. +// stop shuts down the listener and all connections, then +// 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() { if s.cancel != nil { s.cancel() @@ -127,6 +139,22 @@ func (s *Server) stop() { c.conn.Close() //nolint:errcheck,gosec } 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. @@ -152,10 +180,24 @@ func (s *Server) acceptLoop(ctx context.Context) { ) 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.serving.Add(1) s.mu.Unlock() go func() { + defer s.serving.Done() defer func() { s.mu.Lock() delete(s.conns, client) diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index e7f92d4..5cbe493 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -4,6 +4,7 @@ import ( "bufio" "crypto/rand" "database/sql" + "errors" "fmt" "log/slog" "net" @@ -812,6 +813,120 @@ func TestQuit(t *testing.T) { 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) { t.Parallel() diff --git a/internal/server/server.go b/internal/server/server.go index d19f869..49bcd50 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -45,11 +45,8 @@ type Params struct { // It manages routing, middleware, and lifecycle. type Server struct { startupTime time.Time - exitCode int sentryEnabled bool log *slog.Logger - ctx context.Context //nolint:containedctx // signal handling pattern - cancelFunc context.CancelFunc httpServer *http.Server router *chi.Mux params Params @@ -78,13 +75,16 @@ func New( srv.enableSentry() srv.SetupRoutes() - // The start hook's context ends when the hook - // returns; serving must outlive it. - go srv.serve() //nolint:contextcheck,gosec // G118 + // A closed stdout or stderr must not end the process. + signal.Ignore(syscall.SIGPIPE) + + srv.startHTTP() return nil }, - OnStop: func(_ context.Context) error { + OnStop: func(ctx context.Context) error { + srv.cleanShutdown(ctx) + return nil }, }) @@ -129,44 +129,15 @@ func (srv *Server) enableSentry() { 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() { srv.log.Info("cleaning up") } -func (srv *Server) cleanShutdown() { - srv.exitCode = 0 - +// cleanShutdown stops the HTTP server, waiting up to +// shutdownTimeout for the requests in progress to finish. +func (srv *Server) cleanShutdown(ctx context.Context) { ctxShutdown, shutdownCancel := context.WithTimeout( - context.Background(), shutdownTimeout, + ctx, shutdownTimeout, ) err := srv.httpServer.Shutdown(ctxShutdown) @@ -191,7 +162,11 @@ func (srv *Server) configure() { // Server configuration placeholder. } -func (srv *Server) serveUntilShutdown() { +// startHTTP creates the HTTP server and serves it 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() { listenAddr := fmt.Sprintf( ":%d", srv.params.Config.Port, ) @@ -204,16 +179,16 @@ func (srv *Server) serveUntilShutdown() { Handler: srv, } + go srv.serveUntilShutdown() +} + +func (srv *Server) serveUntilShutdown() { srv.log.Info( - "http begin listen", "listenaddr", listenAddr, + "http begin listen", "listenaddr", srv.httpServer.Addr, ) err := srv.httpServer.ListenAndServe() if err != nil && !errors.Is(err, http.ErrServerClosed) { srv.log.Error("listen error", "error", err) - - if srv.cancelFunc != nil { - srv.cancelFunc() - } } } diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..c740ba1 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,247 @@ +package server_test + +import ( + "crypto/rand" + "fmt" + "net" + "net/http" + "strconv" + "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) { + t.Parallel() + + 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") + } +} + +// startApp starts the server with the dependencies +// cmd/neoircd gives it, on a fresh in-memory database, with +// the HTTP server listening on port. +func startApp( + t *testing.T, + port int, +) (*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), + ) + + app.RequireStart() + + // Stops the app if the test ends before stopping it. + t.Cleanup(app.RequireStop) + + 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() + + var listenConfig net.ListenConfig + + listener, err := listenConfig.Listen( + t.Context(), "tcp", "127.0.0.1:0", + ) + if err != nil { + t.Fatalf("listen: %v", err) + } + + tcpAddr, ok := listener.Addr().(*net.TCPAddr) + if !ok { + t.Fatalf("listener address %v is not TCP", listener.Addr()) + } + + err = listener.Close() + if err != nil { + t.Fatalf("close listener: %v", err) + } + + return tcpAddr.Port +} + +// 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 +} -- 2.54.0 From bbdf3a70af5a12f037016aeecff59ab1da9bc9a7 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 21 Oct 2026 01:14:24 +0200 Subject: [PATCH 27/40] Fail startup when the HTTP listener cannot bind (closes #167) The HTTP server ran ListenAndServe in a goroutine, so a failed bind was only logged and neoircd kept running without its HTTP API. The start hook now binds the listen address and returns the bind error, so neoircd exits 1 with an error naming the address. If serving the bound listener later ends with anything other than a normal shutdown, the server stops the app through fx.Shutdowner with exit code 1; the handler tests now pass it that Shutdowner. Tests check that a taken port fails startup, that a serve failure stops the app with exit code 1, and that a normal stop does not. The server tests no longer run in parallel: building the app writes the global viper instance. Model: opus-5-5 --- internal/handlers/api_test.go | 2 + internal/server/export_test.go | 12 ++- internal/server/server.go | 50 ++++++++---- internal/server/server_test.go | 143 +++++++++++++++++++++++++++++---- 4 files changed, 176 insertions(+), 31 deletions(-) diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index fca94c9..2d0800c 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -325,6 +325,7 @@ func newTestServerFx( cfg *config.Config, mware *middleware.Middleware, hdlr *handlers.Handlers, + shutdowner fx.Shutdowner, ) (*server.Server, error) { srv, err := server.New(lifecycle, server.Params{ //nolint:exhaustruct Logger: log, @@ -332,6 +333,7 @@ func newTestServerFx( Config: cfg, Middleware: mware, Handlers: hdlr, + Shutdowner: shutdowner, }) if err != nil { return nil, fmt.Errorf("test server: %w", err) diff --git a/internal/server/export_test.go b/internal/server/export_test.go index 656b378..3908b62 100644 --- a/internal/server/export_test.go +++ b/internal/server/export_test.go @@ -1,6 +1,10 @@ package server -import "github.com/go-chi/chi/v5" +import ( + "net" + + "github.com/go-chi/chi/v5" +) // NewTestSPAServer creates a Server whose router serves only // the embedded web client. @@ -10,3 +14,9 @@ func NewTestSPAServer() *Server { 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) +} diff --git a/internal/server/server.go b/internal/server/server.go index 49bcd50..0bd8951 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + "net" "net/http" "os" "os/signal" @@ -39,6 +40,7 @@ type Params struct { Config *config.Config Middleware *middleware.Middleware Handlers *handlers.Handlers + Shutdowner fx.Shutdowner } // Server is the main HTTP server. @@ -66,7 +68,7 @@ func New( } lifecycle.Append(fx.Hook{ - OnStart: func(_ context.Context) error { + OnStart: func(ctx context.Context) error { srv.startupTime = time.Now() // Build the router before OnStart returns, so srv can @@ -78,9 +80,7 @@ func New( // A closed stdout or stderr must not end the process. signal.Ignore(syscall.SIGPIPE) - srv.startHTTP() - - return nil + return srv.startHTTP(ctx) }, OnStop: func(ctx context.Context) error { srv.cleanShutdown(ctx) @@ -162,15 +162,23 @@ func (srv *Server) configure() { // Server configuration placeholder. } -// startHTTP creates the HTTP server and serves it 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() { +// startHTTP binds the HTTP listen address, so that a port +// 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( ":%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 Addr: listenAddr, ReadTimeout: httpReadTimeout, @@ -179,16 +187,30 @@ func (srv *Server) startHTTP() { Handler: srv, } - go srv.serveUntilShutdown() + go func() { + srv.serveUntilShutdown(listener) + }() + + return nil } -func (srv *Server) serveUntilShutdown() { +// 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( "http begin listen", "listenaddr", srv.httpServer.Addr, ) - err := srv.httpServer.ListenAndServe() - if err != nil && !errors.Is(err, http.ErrServerClosed) { - srv.log.Error("listen error", "error", err) + err := srv.httpServer.Serve(listener) + if errors.Is(err, http.ErrServerClosed) { + return + } + + srv.log.Error("listen error", "error", err) + + err = srv.params.Shutdowner.Shutdown(fx.ExitCode(1)) + if err != nil { + srv.log.Error("shutdown failed", "error", err) } } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index c740ba1..148f28e 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1,3 +1,7 @@ +// Tests use a global viper instance for configuration, +// making parallel execution unsafe. +// +//nolint:paralleltest package server_test import ( @@ -6,6 +10,7 @@ import ( "net" "net/http" "strconv" + "strings" "testing" "time" @@ -40,8 +45,6 @@ const ( // for a long poll the HTTP server is serving to finish, and // that the poll gets its response. func TestStopWaitsForRequestInProgress(t *testing.T) { - t.Parallel() - port := freePort(t) addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) @@ -103,12 +106,114 @@ func TestStopWaitsForRequestInProgress(t *testing.T) { } } -// startApp starts the server with the dependencies -// cmd/neoircd gives it, on a fresh in-memory database, with -// the HTTP server listening on port. +// 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() @@ -146,13 +251,9 @@ func startApp( }), fx.Invoke(func(*server.Server) {}), fx.Populate(&brk, &database), + fx.Options(options...), ) - app.RequireStart() - - // Stops the app if the test ends before stopping it. - t.Cleanup(app.RequireStop) - return app, brk, database } @@ -161,6 +262,21 @@ func startApp( 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( @@ -170,17 +286,12 @@ func freePort(t *testing.T) int { t.Fatalf("listen: %v", err) } - tcpAddr, ok := listener.Addr().(*net.TCPAddr) - if !ok { - t.Fatalf("listener address %v is not TCP", listener.Addr()) - } - err = listener.Close() if err != nil { t.Fatalf("close listener: %v", err) } - return tcpAddr.Port + return listener } // canConnect reports whether addr accepts a TCP connection. -- 2.54.0 From b879644e4180b10e9ebff398e394c3e6262eecf5 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 21 Oct 2026 01:48:26 +0200 Subject: [PATCH 28/40] Broadcast a session's QUIT once when its clients leave together (closes #154) Logout deleted the client, counted the session's clients and ran BroadcastQuit as separate statements, and BroadcastQuit read the peers, parted, deleted empty channels and deleted the session separately too. Two logouts or QUITs of one session at once both sent its QUIT, and a JOIN landing between a part and the channel's deletion got into a +k channel without the key. db.QuitSession now does all of that in one transaction, parting each channel as PartChannelAndDeleteIfEmpty does, and stores and queues the QUIT in it only when it deleted the session, so the QUIT is queued before the nick is free. db.LogoutClient deletes the client and counts the rest in the same transaction. Model: opus-5-5 --- README.md | 11 +- internal/db/queries.go | 332 +++++++++++++++------ internal/db/queries_test.go | 157 ++++++++-- internal/handlers/api.go | 27 +- internal/handlers/api_test.go | 496 ++++++++++++++++++++++++++++++- internal/service/service.go | 74 +++-- internal/service/service_test.go | 202 +++++++++++++ 7 files changed, 1110 insertions(+), 189 deletions(-) diff --git a/README.md b/README.md index 6f983ab..36caadd 100644 --- a/README.md +++ b/README.md @@ -2569,10 +2569,13 @@ issues) and simpler than UUIDs (integer comparison vs. string comparison). - **Sessions**: Both anonymous and password-protected sessions are deleted on `QUIT` or when the last client logs out (`POST /api/v1/logout` with no remaining clients triggers session cleanup). There is no distinction between - session types in the cleanup path — `handleQuit` and `cleanupUser` both call - `DeleteSession` unconditionally. Idle clients are automatically removed after - `SESSION_IDLE_TIMEOUT` (default 30 days) without an authenticated HTTP API - request or, on the IRC listener, a command — the server runs a background + session types in the cleanup path: the session is parted from its channels, + deleted, and its QUIT queued to its channel peers in one database transaction, + so when several QUITs or logouts end one session at once only the one that + deletes it broadcasts QUIT, and whatever a new holder of the nick sends + reaches those peers after the QUIT. Idle clients are automatically removed + after `SESSION_IDLE_TIMEOUT` (default 30 days) without an authenticated HTTP + API request or, on the IRC listener, a command — the server runs a background cleanup loop that, for a session left with no clients, parts the user from all channels, broadcasts QUIT, and releases the nick. - **Clients**: Individual client auth cookies are invalidated on diff --git a/internal/db/queries.go b/internal/db/queries.go index 3db2e63..8b216a1 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -854,42 +854,6 @@ func (database *Database) SetChannelTopicLocked( return nil } -// PartChannel removes a session from a channel. -func (database *Database) PartChannel( - ctx context.Context, - channelID, sessionID int64, -) error { - _, err := database.conn.ExecContext(ctx, - `DELETE FROM channel_members - WHERE channel_id = ? AND session_id = ?`, - channelID, sessionID) - if err != nil { - return fmt.Errorf("part channel: %w", err) - } - - return nil -} - -// DeleteChannelIfEmpty removes a channel with no members. -func (database *Database) DeleteChannelIfEmpty( - ctx context.Context, - channelID int64, -) error { - _, err := database.conn.ExecContext(ctx, - `DELETE FROM channels WHERE id = ? - AND NOT EXISTS - (SELECT 1 FROM channel_members - WHERE channel_id = ?)`, - channelID, channelID) - if err != nil { - return fmt.Errorf( - "delete channel if empty: %w", err, - ) - } - - return nil -} - // PartChannelAndDeleteIfEmpty removes a session from a // channel and deletes the channel if that left it with no // members, in one transaction, so that no JOIN lands @@ -905,7 +869,29 @@ func (database *Database) PartChannelAndDeleteIfEmpty( defer func() { _ = transaction.Rollback() }() - _, err = transaction.ExecContext(ctx, + err = partChannelAndDeleteIfEmptyInTx( + ctx, transaction, channelID, sessionID, + ) + if err != nil { + return err + } + + err = transaction.Commit() + if err != nil { + return fmt.Errorf("commit part: %w", err) + } + + return nil +} + +// partChannelAndDeleteIfEmptyInTx is +// PartChannelAndDeleteIfEmpty, writing through transaction. +func partChannelAndDeleteIfEmptyInTx( + ctx context.Context, + transaction *sql.Tx, + channelID, sessionID int64, +) error { + _, err := transaction.ExecContext(ctx, `DELETE FROM channel_members WHERE channel_id = ? AND session_id = ?`, channelID, sessionID) @@ -925,11 +911,6 @@ func (database *Database) PartChannelAndDeleteIfEmpty( ) } - err = transaction.Commit() - if err != nil { - return fmt.Errorf("commit part: %w", err) - } - return nil } @@ -1156,6 +1137,40 @@ func (database *Database) InsertMessage( params json.RawMessage, body json.RawMessage, meta json.RawMessage, +) (int64, string, error) { + transaction, err := database.conn.BeginTx(ctx, nil) + if err != nil { + return 0, "", fmt.Errorf("begin insert message: %w", err) + } + + defer func() { _ = transaction.Rollback() }() + + dbID, msgUUID, err := insertMessageInTx( + ctx, transaction, command, from, target, + toSessionID, toChannelID, params, body, meta, + ) + if err != nil { + return 0, "", err + } + + err = transaction.Commit() + if err != nil { + return 0, "", fmt.Errorf("commit insert message: %w", err) + } + + return dbID, msgUUID, nil +} + +// insertMessageInTx is InsertMessage, writing through +// transaction. +func insertMessageInTx( + ctx context.Context, + transaction *sql.Tx, + command, from, target string, + toSessionID, toChannelID int64, + params json.RawMessage, + body json.RawMessage, + meta json.RawMessage, ) (int64, string, error) { msgUUID := uuid.New().String() now := time.Now().UTC() @@ -1172,7 +1187,7 @@ func (database *Database) InsertMessage( meta = json.RawMessage("{}") } - res, err := database.conn.ExecContext(ctx, + res, err := transaction.ExecContext(ctx, `INSERT INTO messages (uuid, command, msg_from, msg_to, to_session_id, to_channel_id, @@ -1199,7 +1214,34 @@ func (database *Database) EnqueueToSession( ctx context.Context, sessionID, messageID int64, ) error { - _, err := database.conn.ExecContext(ctx, + transaction, err := database.conn.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin enqueue: %w", err) + } + + defer func() { _ = transaction.Rollback() }() + + err = enqueueToSessionInTx(ctx, transaction, sessionID, messageID) + if err != nil { + return err + } + + err = transaction.Commit() + if err != nil { + return fmt.Errorf("commit enqueue: %w", err) + } + + return nil +} + +// enqueueToSessionInTx is EnqueueToSession, writing through +// transaction. +func enqueueToSessionInTx( + ctx context.Context, + transaction *sql.Tx, + sessionID, messageID int64, +) error { + _, err := transaction.ExecContext(ctx, `INSERT OR IGNORE INTO client_queues (client_id, message_id, created_at) SELECT c.id, ?, ? @@ -1470,38 +1512,175 @@ func (database *Database) SetTopic( return nil } -// DeleteSession removes a session and all its data. -func (database *Database) DeleteSession( +// QuitSession ends a session in one transaction: it parts +// the session from its channels, deleting each channel this +// leaves with no members, deletes the session with its +// clients, and stores a QUIT from nick with body and queues +// it to the other sessions that were on those channels. It +// returns those sessions, for the caller to wake once the +// QUIT is committed. Of several calls for one session at +// once, only the first finds the session; the others change +// nothing and return none. +func (database *Database) QuitSession( ctx context.Context, sessionID int64, -) error { - _, err := database.conn.ExecContext( - ctx, - "DELETE FROM sessions WHERE id = ?", - sessionID, - ) + nick string, + body json.RawMessage, +) ([]int64, error) { + transaction, err := database.conn.BeginTx(ctx, nil) if err != nil { - return fmt.Errorf("delete session: %w", err) + return nil, fmt.Errorf("begin quit: %w", err) } - return nil + defer func() { _ = transaction.Rollback() }() + + peerIDs, err := quitSessionInTx( + ctx, transaction, sessionID, nick, body, + ) + if err != nil { + return nil, err + } + + err = transaction.Commit() + if err != nil { + return nil, fmt.Errorf("commit quit: %w", err) + } + + return peerIDs, nil } -// DeleteClient removes a single client record by ID. -func (database *Database) DeleteClient( +// LogoutClient deletes a client of a session and, if that +// leaves the session with no clients, does what QuitSession +// does, in one transaction. It returns what QuitSession +// returns, or none if the session keeps a client. +func (database *Database) LogoutClient( ctx context.Context, - clientID int64, -) error { - _, err := database.conn.ExecContext( - ctx, - "DELETE FROM clients WHERE id = ?", - clientID, - ) + sessionID, clientID int64, + nick string, + body json.RawMessage, +) ([]int64, error) { + transaction, err := database.conn.BeginTx(ctx, nil) if err != nil { - return fmt.Errorf("delete client: %w", err) + return nil, fmt.Errorf("begin logout: %w", err) } - return nil + defer func() { _ = transaction.Rollback() }() + + _, err = transaction.ExecContext(ctx, + "DELETE FROM clients WHERE id = ?", clientID) + if err != nil { + return nil, fmt.Errorf("delete client: %w", err) + } + + var remaining int64 + + err = transaction.QueryRowContext(ctx, + "SELECT COUNT(*) FROM clients WHERE session_id = ?", + sessionID, + ).Scan(&remaining) + if err != nil { + return nil, fmt.Errorf("count clients: %w", err) + } + + var peerIDs []int64 + + if remaining == 0 { + peerIDs, err = quitSessionInTx( + ctx, transaction, sessionID, nick, body, + ) + if err != nil { + return nil, err + } + } + + err = transaction.Commit() + if err != nil { + return nil, fmt.Errorf("commit logout: %w", err) + } + + return peerIDs, nil +} + +// quitSessionInTx is QuitSession, writing through +// transaction. +func quitSessionInTx( + ctx context.Context, + transaction *sql.Tx, + sessionID int64, + nick string, + body json.RawMessage, +) ([]int64, error) { + rows, err := transaction.QueryContext(ctx, + `SELECT DISTINCT session_id FROM channel_members + WHERE session_id != ? AND channel_id IN + (SELECT channel_id FROM channel_members + WHERE session_id = ?)`, + sessionID, sessionID) + if err != nil { + return nil, fmt.Errorf("get channel peers: %w", err) + } + + peerIDs, err := scanInt64s(rows) + if err != nil { + return nil, err + } + + rows, err = transaction.QueryContext(ctx, + `SELECT channel_id FROM channel_members + WHERE session_id = ?`, sessionID) + if err != nil { + return nil, fmt.Errorf( + "get session channel ids: %w", err, + ) + } + + channelIDs, err := scanInt64s(rows) + if err != nil { + return nil, err + } + + for _, channelID := range channelIDs { + err = partChannelAndDeleteIfEmptyInTx( + ctx, transaction, channelID, sessionID, + ) + if err != nil { + return nil, err + } + } + + result, err := transaction.ExecContext(ctx, + "DELETE FROM sessions WHERE id = ?", sessionID) + if err != nil { + return nil, fmt.Errorf("delete session: %w", err) + } + + deletedRows, err := result.RowsAffected() + if err != nil { + return nil, fmt.Errorf("delete session: %w", err) + } + + if deletedRows == 0 || len(peerIDs) == 0 { + return nil, nil + } + + messageID, _, err := insertMessageInTx( + ctx, transaction, irc.CmdQuit, nick, "", 0, 0, + nil, body, nil, + ) + if err != nil { + return nil, err + } + + for _, peerID := range peerIDs { + err = enqueueToSessionInTx( + ctx, transaction, peerID, messageID, + ) + if err != nil { + return nil, err + } + } + + return peerIDs, nil } // GetUserCount returns the number of active users. @@ -1543,29 +1722,6 @@ func (database *Database) GetOperCount( return count, nil } -// ClientCountForSession returns the number of clients -// belonging to a session. -func (database *Database) ClientCountForSession( - ctx context.Context, - sessionID int64, -) (int64, error) { - var count int64 - - err := database.conn.QueryRowContext( - ctx, - `SELECT COUNT(*) FROM clients - WHERE session_id = ?`, - sessionID, - ).Scan(&count) - if err != nil { - return 0, fmt.Errorf( - "client count for session: %w", err, - ) - } - - return count, nil -} - // DeleteStaleUsers removes clients not seen since the // cutoff and cleans up orphaned users (sessions). func (database *Database) DeleteStaleUsers( diff --git a/internal/db/queries_test.go b/internal/db/queries_test.go index 99fad5f..29f9928 100644 --- a/internal/db/queries_test.go +++ b/internal/db/queries_test.go @@ -2,6 +2,7 @@ package db_test import ( "encoding/json" + "slices" "testing" "sneak.berlin/go/neoirc/internal/db" @@ -411,7 +412,7 @@ func TestJoinAndPart(t *testing.T) { t.Fatal(err) } - err = database.PartChannel(ctx, chID, sid) + err = database.PartChannelAndDeleteIfEmpty(ctx, chID, sid) if err != nil { t.Fatal(err) } @@ -422,7 +423,10 @@ func TestJoinAndPart(t *testing.T) { } } -func TestDeleteChannelIfEmpty(t *testing.T) { +// TestPartChannelAndDeleteIfEmpty checks that a part keeps +// a channel another session is still on and deletes the +// channel when its last member parts. +func TestPartChannelAndDeleteIfEmpty(t *testing.T) { t.Parallel() database := setupTestDB(t) @@ -435,22 +439,34 @@ func TestDeleteChannelIfEmpty(t *testing.T) { t.Fatal(err) } - sid, _, _, err := database.CreateSession(ctx, "temp", "", "", "") + nicks := []string{"temp1", "temp2"} + 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 { t.Fatal(err) } - err = database.JoinChannel(ctx, chID, sid) + _, err = database.GetChannelByName(ctx, "#empty") if err != nil { - t.Fatal(err) + t.Fatalf("channel deleted while a member is on it: %v", err) } - err = database.PartChannel(ctx, chID, sid) - if err != nil { - t.Fatal(err) - } - - err = database.DeleteChannelIfEmpty(ctx, chID) + err = database.PartChannelAndDeleteIfEmpty(ctx, chID, sids[1]) if err != nil { t.Fatal(err) } @@ -777,6 +793,11 @@ func TestGetHistoryReusedNames(t *testing.T) { t.Fatal(err) } + err = database.JoinChannel(ctx, oldChID, oldSID) + if err != nil { + t.Fatal(err) + } + body := json.RawMessage(`["hi"]`) _, _, err = database.InsertMessage( @@ -799,12 +820,9 @@ func TestGetHistoryReusedNames(t *testing.T) { t.Fatal("expected one DM and one channel message") } - err = database.DeleteSession(ctx, oldSID) - if err != nil { - t.Fatal(err) - } - - err = database.DeleteChannelIfEmpty(ctx, oldChID) + // The session is the channel's only member, so quitting + // deletes the channel too. + _, err = database.QuitSession(ctx, oldSID, "reused", nil) if err != nil { t.Fatal(err) } @@ -826,34 +844,42 @@ func TestGetHistoryReusedNames(t *testing.T) { } } -func TestDeleteSession(t *testing.T) { +// 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, _, _, err := database.CreateSession( - ctx, "deleteme", "", "", "", + sid, sharedID, _ := createSessionWithChannels( + t, database, "deleteme", "#shared", "#alone", + ) + + peerSID, peerClientID, _, err := database.CreateSession( + ctx, "peer", "", "", "", ) if err != nil { t.Fatal(err) } - chID, err := database.GetOrCreateChannel( - ctx, "#delchan", - ) + err = database.JoinChannel(ctx, sharedID, peerSID) if err != nil { t.Fatal(err) } - err = database.JoinChannel(ctx, chID, sid) + body := json.RawMessage(`["bye"]`) + + peerIDs, err := database.QuitSession(ctx, sid, "deleteme", body) if err != nil { t.Fatal(err) } - err = database.DeleteSession(ctx, sid) - 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") @@ -861,9 +887,80 @@ func TestDeleteSession(t *testing.T) { t.Fatal("session should be deleted") } - ids, _ := database.GetChannelMemberIDs(ctx, chID) - if len(ids) != 0 { - t.Fatal("expected no members after deletion") + ids, _ := database.GetChannelMemberIDs(ctx, sharedID) + if !slices.Equal(ids, []int64{peerSID}) { + t.Fatalf("#shared members: got %v, want [%d]", ids, peerSID) + } + + _, 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) } } diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 9e10b46..5f73c49 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -3395,15 +3395,11 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc { return } - ctx := request.Context() - - err := hdlr.params.Database.DeleteClient( - ctx, clientID, + err := hdlr.svc.Logout( + request.Context(), sessionID, clientID, nick, ) if err != nil { - hdlr.log.Error( - "delete client failed", "error", err, - ) + hdlr.log.Error("logout failed", "error", err) hdlr.respondError( writer, request, "internal error", @@ -3413,23 +3409,6 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc { 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.respondJSON(writer, request, diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 2d0800c..66985a3 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -126,6 +126,7 @@ const ( privmsgCmd = "PRIVMSG" joinCmd = "JOIN" apiMessages = "/api/v1/messages" + apiLogout = "/api/v1/logout" apiSession = "/api/v1/session" apiState = "/api/v1/state" authCookieName = "neoirc_auth" @@ -136,6 +137,8 @@ type testServer struct { httpServer *httptest.Server t *testing.T fxApp *fxtest.App + broker *broker.Broker + database *db.Database } func newTestServer( @@ -162,7 +165,11 @@ func newTestServerWith( ) copyTemplateDB(t, dbURL) - var srv *server.Server + var ( + srv *server.Server + brk *broker.Broker + database *db.Database + ) app := fxtest.New(t, fx.Provide( @@ -195,13 +202,14 @@ func newTestServerWith( return cfg, nil }, newTestDB, + broker.New, stats.New, newTestHealthcheck, newTestMiddleware, newTestHandlers, newTestServerFx, ), - fx.Populate(&srv), + fx.Populate(&srv, &brk, &database), ) app.RequireStart() @@ -217,6 +225,8 @@ func newTestServerWith( httpServer: httpSrv, t: t, fxApp: app, + broker: brk, + database: database, } } @@ -291,8 +301,8 @@ func newTestHandlers( database *db.Database, hcheck *healthcheck.Healthcheck, tracker *stats.Tracker, + brk *broker.Broker, ) (*handlers.Handlers, error) { - brk := broker.New() svc := service.New(service.Params{ //nolint:exhaustruct Logger: log, Config: cfg, @@ -1395,6 +1405,486 @@ func TestQuit(t *testing.T) { } } +// quitRaceTrials is how many times checkQuitRace repeats +// its race. +const quitRaceTrials = 5 + +// TestConcurrentLogoutsSendOneQuit checks that when both +// clients of a session log out at once, the session's QUIT +// is sent once. +func TestConcurrentLogoutsSendOneQuit(t *testing.T) { + checkQuitRace(t, func(tserver *testServer, tokens []string) { + tserver.postConcurrently(apiLogout, nil, tokens...) + }) +} + +// TestConcurrentQuitsSendOneQuit checks that when both +// clients of a session send QUIT at once, the session's +// QUIT is sent once. +func TestConcurrentQuitsSendOneQuit(t *testing.T) { + quit, err := json.Marshal(map[string]any{commandKey: irc.CmdQuit}) + if err != nil { + t.Fatal(err) + } + + checkQuitRace(t, func(tserver *testServer, tokens []string) { + tserver.postConcurrently(apiMessages, quit, tokens...) + }) +} + +// checkQuitRace gives a session a password and two +// clients, puts it on a channel with a second session, and +// calls leave with the tokens of the two clients. It checks +// that the second session then holds one QUIT from the +// first, and that the first session is deleted: its tokens +// are refused and its nick is free. It does all this +// quitRaceTrials times, each on a new server, because each +// trial logs in and logins are rate limited. +func checkQuitRace( + t *testing.T, + leave func(tserver *testServer, tokens []string), +) { + t.Helper() + + const nick = "racingquitter" + + for trial := range quitRaceTrials { + tserver := newTestServer(t) + firstToken := tserver.createSession(nick) + observerToken := tserver.createSession("raceobserver") + + tserver.sendCommand(firstToken, map[string]any{ + commandKey: irc.CmdPass, bodyKey: []string{testPassword}, + }) + + tokens := []string{firstToken, tserver.login(nick)} + + for _, token := range []string{firstToken, observerToken} { + tserver.sendCommand(token, map[string]any{ + commandKey: joinCmd, toKey: "#quitrace", + }) + } + + _, lastID := tserver.pollMessages(observerToken, 0) + + leave(tserver, tokens) + + msgs, _ := tserver.pollMessages(observerToken, lastID) + + quits := countQuits(msgs, nick) + if quits != 1 { + t.Errorf("trial %d: %d QUITs, want 1: %v", trial, quits, msgs) + } + + for _, token := range tokens { + status, _ := tserver.getState(token) + if status != http.StatusUnauthorized { + t.Errorf("trial %d: state got %d, want 401", trial, status) + } + } + + // createSession fails the test if the nick is taken. + tserver.createSession(nick) + } +} + +// TestLogoutOfOneClientKeepsSession checks that when one of +// a session's two clients logs out, the other client's +// token still works, the session stays on its channel and +// its channel peer gets no QUIT, and that the other +// client's logout then sends one QUIT. +func TestLogoutOfOneClientKeepsSession(t *testing.T) { + const ( + nick = "twoclients" + channel = "#logouts" + ) + + tserver := newTestServer(t) + firstToken := tserver.createSession(nick) + observerToken := tserver.createSession("logoutobserver") + + tserver.sendCommand(firstToken, map[string]any{ + commandKey: irc.CmdPass, bodyKey: []string{testPassword}, + }) + + secondToken := tserver.login(nick) + + for _, token := range []string{firstToken, observerToken} { + tserver.sendCommand(token, map[string]any{ + commandKey: joinCmd, toKey: channel, + }) + } + + _, lastID := tserver.pollMessages(observerToken, 0) + + tserver.logout(firstToken) + + status, _ := tserver.getState(firstToken) + if status != http.StatusUnauthorized { + t.Errorf("logged-out client's state: got %d, want 401", status) + } + + status, state := tserver.getState(secondToken) + if status != http.StatusOK { + t.Fatalf("other client's state: got %d, want 200", status) + } + + names := channelNames(state) + if !slices.Equal(names, []string{channel}) { + t.Errorf("session's channels: got %v, want [%s]", names, channel) + } + + msgs, lastID := tserver.pollMessages(observerToken, lastID) + + quits := countQuits(msgs, nick) + if quits != 0 { + t.Errorf("after the first logout: %d QUITs, want 0: %v", quits, msgs) + } + + tserver.logout(secondToken) + + msgs, _ = tserver.pollMessages(observerToken, lastID) + + quits = countQuits(msgs, nick) + if quits != 1 { + t.Errorf("after the second logout: %d QUITs, want 1: %v", quits, msgs) + } +} + +// TestFailedLogoutChangesNothing checks that a logout whose +// session cannot be deleted answers 500 and changes +// nothing: the client's token still works, the session is +// still on its channel and its channel peer has no QUIT. +func TestFailedLogoutChangesNothing(t *testing.T) { + const ( + nick = "stuckleaver" + channel = "#stuck" + ) + + tserver := newTestServer(t) + token := tserver.createSession(nick) + observerToken := tserver.createSession("stuckobserver") + + for _, joiner := range []string{token, observerToken} { + tserver.sendCommand(joiner, map[string]any{ + commandKey: joinCmd, toKey: channel, + }) + } + + _, lastID := tserver.pollMessages(observerToken, 0) + + _, err := tserver.database.GetDB().ExecContext(t.Context(), + `CREATE TRIGGER keep_sessions + BEFORE DELETE ON sessions + BEGIN + SELECT RAISE(ABORT, 'sessions are kept'); + END`) + if err != nil { + t.Fatal(err) + } + + resp, err := doRequestAuth( + t, http.MethodPost, tserver.url(apiLogout), token, nil, + ) + if err != nil { + t.Fatalf("logout: %v", err) + } + + _ = resp.Body.Close() + + if resp.StatusCode != http.StatusInternalServerError { + t.Errorf("logout: got %d, want 500", resp.StatusCode) + } + + status, state := tserver.getState(token) + if status != http.StatusOK { + t.Fatalf("state after the failed logout: got %d, want 200", status) + } + + names := channelNames(state) + if !slices.Equal(names, []string{channel}) { + t.Errorf("session's channels: got %v, want [%s]", names, channel) + } + + msgs, _ := tserver.pollMessages(observerToken, lastID) + + quits := countQuits(msgs, nick) + if quits != 0 { + t.Errorf("%d QUITs, want 0: %v", quits, msgs) + } +} + +const ( + // quitWakePollTimeout is the timeout, in seconds, of the + // long poll in checkQuitWakesPoll. + quitWakePollTimeout = 10 + + // quitWakeDeadline is how long checkQuitWakesPoll waits + // for that long poll to start waiting, and then to + // return once the session has left. + quitWakeDeadline = 5 * time.Second + + // pollWaitStep is how often checkQuitWakesPoll checks + // whether the long poll is waiting yet. + pollWaitStep = 10 * time.Millisecond +) + +// TestQuitWakesPeerLongPoll checks that a session's QUIT +// wakes the long poll of a user who shares a channel with +// it. +func TestQuitWakesPeerLongPoll(t *testing.T) { + const reason = "gone fishing" + + checkQuitWakesPoll(t, reason, func(tserver *testServer, token string) { + tserver.sendCommand(token, map[string]any{ + commandKey: irc.CmdQuit, bodyKey: []string{reason}, + }) + }) +} + +// TestLastLogoutWakesPeerLongPoll checks that the logout of +// a session's last client wakes the long poll of a user who +// shares a channel with it. +func TestLastLogoutWakesPeerLongPoll(t *testing.T) { + checkQuitWakesPoll(t, "Connection closed", func(tserver *testServer, token string) { + tserver.logout(token) + }) +} + +// checkQuitWakesPoll puts a session and a peer on a +// channel, and calls leave with the session's token while +// the peer long-polls with a timeout of quitWakePollTimeout +// seconds. It checks that the poll returns within +// quitWakeDeadline with the session's QUIT, whose body is +// reason. +func checkQuitWakesPoll( + t *testing.T, + reason string, + leave func(tserver *testServer, token string), +) { + t.Helper() + + const ( + nick = "wakequitter" + peerNick = "wakepeer" + ) + + tserver := newTestServer(t) + token := tserver.createSession(nick) + peerToken := tserver.createSession(peerNick) + + for _, joiner := range []string{token, peerToken} { + tserver.sendCommand(joiner, map[string]any{ + commandKey: joinCmd, toKey: "#wake", + }) + } + + _, lastID := tserver.pollMessages(peerToken, 0) + + peerID, err := tserver.database.GetSessionByNick(t.Context(), peerNick) + if err != nil { + t.Fatalf("get session of %s: %v", peerNick, err) + } + + var ( + msgs []map[string]any + pollErr error + ) + + polled := make(chan struct{}) + + go func() { + defer close(polled) + + msgs, pollErr = tserver.longPoll(peerToken, lastID, quitWakePollTimeout) + }() + + deadline := time.Now().Add(quitWakeDeadline) + + for tserver.broker.ListenerCount(peerID) == 0 { + if time.Now().After(deadline) { + t.Fatal("the long poll did not start waiting") + } + + time.Sleep(pollWaitStep) + } + + leave(tserver, token) + + select { + case <-polled: + case <-time.After(quitWakeDeadline): + t.Fatalf("the long poll did not return within %v", quitWakeDeadline) + } + + if pollErr != nil { + t.Fatalf("long poll: %v", pollErr) + } + + if len(msgs) != 1 || countQuits(msgs, nick) != 1 { + t.Fatalf("long poll: got %v, want one QUIT from %s", msgs, nick) + } + + lines, _ := msgs[0][bodyKey].([]any) + if len(lines) != 1 || lines[0] != reason { + t.Errorf("QUIT body: got %v, want [%s]", lines, reason) + } +} + +// channelNames returns the names of the channels in state, +// a response to GET /api/v1/state. +func channelNames(state map[string]any) []string { + channels, _ := state["channels"].([]any) + names := make([]string, 0, len(channels)) + + for _, entry := range channels { + info, _ := entry.(map[string]any) + name, _ := info["name"].(string) + names = append(names, name) + } + + return names +} + +// countQuits returns how many of msgs are a QUIT from nick. +func countQuits(msgs []map[string]any, nick string) int { + quits := 0 + + for _, msg := range msgs { + if msg[commandKey] == irc.CmdQuit && msg["from"] == nick { + quits++ + } + } + + return quits +} + +// logout logs out the client token and fails the test +// unless the server answers 200. +func (tserver *testServer) logout(token string) { + tserver.t.Helper() + + resp, err := doRequestAuth( + tserver.t, http.MethodPost, + tserver.url(apiLogout), token, nil, + ) + if err != nil { + tserver.t.Fatalf("logout: %v", err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + tserver.t.Fatalf("logout: status %d", resp.StatusCode) + } +} + +// longPoll returns the messages after afterID that the +// client token has been sent, waiting up to timeout seconds +// for one if there are none yet. It returns errors instead +// of failing the test, so a goroutine can call it. +func (tserver *testServer) longPoll( + token string, afterID int64, timeout int, +) ([]map[string]any, error) { + resp, err := doRequestAuth( + tserver.t, http.MethodGet, + tserver.url(fmt.Sprintf( + "%s?timeout=%d&after=%d", apiMessages, timeout, afterID, + )), + token, nil, + ) + if err != nil { + return nil, err + } + + defer func() { _ = resp.Body.Close() }() + + var result struct { + Messages []map[string]any `json:"messages"` + } + + err = json.NewDecoder(resp.Body).Decode(&result) + if err != nil { + return nil, fmt.Errorf("decode poll: %w", err) + } + + return result.Messages, nil +} + +// login logs in to the session nick with testPassword and +// returns the new client's token. +func (tserver *testServer) login(nick string) string { + tserver.t.Helper() + + body, err := json.Marshal(map[string]string{ + nickKey: nick, passwordKey: testPassword, + }) + if err != nil { + tserver.t.Fatalf("marshal login: %v", err) + } + + resp, err := doRequest( + tserver.t, http.MethodPost, + tserver.url("/api/v1/login"), bytes.NewReader(body), + ) + if err != nil { + tserver.t.Fatalf("login: %v", err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + tserver.t.Fatalf("login: status %d", resp.StatusCode) + } + + for _, cookie := range resp.Cookies() { + if cookie.Name == authCookieName { + return cookie.Value + } + } + + tserver.t.Fatal("no auth cookie in login response") + + return "" +} + +// postConcurrently POSTs body to path once with each of +// tokens, all at once. +func (tserver *testServer) postConcurrently( + path string, body []byte, tokens ...string, +) { + tserver.t.Helper() + + start := make(chan struct{}) + + var waitGroup sync.WaitGroup + + for _, token := range tokens { + waitGroup.Add(1) + + go func() { + defer waitGroup.Done() + + <-start + + resp, err := doRequestAuth( + tserver.t, http.MethodPost, tserver.url(path), + token, bytes.NewReader(body), + ) + if err != nil { + tserver.t.Error(err) + + return + } + + _ = resp.Body.Close() + }() + } + + close(start) + waitGroup.Wait() +} + func TestUnknownCommand(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession("cmdtest") diff --git a/internal/service/service.go b/internal/service/service.go index dabe74f..ea75f7a 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -826,61 +826,55 @@ func (s *Service) ChangeNick( return nil } -// BroadcastQuit broadcasts a QUIT to all channel peers, -// parts all channels, and deletes the session. Uses the -// FanOut pattern: one message row fanned out to all unique -// peer sessions. +// BroadcastQuit parts the session from all its channels, +// deletes it, and queues a QUIT to the users who shared a +// channel with it, in one transaction, so that the QUIT is +// queued before anyone else can take the nick, and then +// wakes those users' long polls. Of several calls for one +// session at once, only the one that deletes it sends the +// QUIT. func (s *Service) BroadcastQuit( ctx context.Context, sessionID int64, nick, reason string, ) { - channels, err := s.db.GetSessionChannels( - ctx, sessionID, - ) + body, _ := json.Marshal([]string{reason}) //nolint:errchkjson + + peerIDs, err := s.db.QuitSession(ctx, sessionID, nick, body) if err != nil { + s.log.Error("quit failed", "error", err) + return } - notified := make(map[int64]bool) + for _, peerID := range peerIDs { + s.broker.Notify(peerID) + } +} - for _, ch := range channels { - memberIDs, memErr := s.db.GetChannelMemberIDs( - ctx, ch.ID, - ) - if memErr != nil { - continue - } +// Logout deletes a client of the session and, if that was +// the session's last client, does what BroadcastQuit does. +// If that fails, nothing changes and Logout returns the +// error. +func (s *Service) Logout( + ctx context.Context, + sessionID, clientID int64, + nick string, +) error { + body, _ := json.Marshal([]string{"Connection closed"}) //nolint:errchkjson - for _, mid := range memberIDs { - if mid == sessionID || notified[mid] { - continue - } - - notified[mid] = true - } + peerIDs, err := s.db.LogoutClient( + ctx, sessionID, clientID, nick, body, + ) + if err != nil { + return fmt.Errorf("logout: %w", err) } - if len(notified) > 0 { - recipients := make([]int64, 0, len(notified)) - for sid := range notified { - recipients = append(recipients, sid) - } - - body, _ := json.Marshal([]string{reason}) //nolint:errchkjson - - _, _, _ = s.FanOut( - ctx, irc.CmdQuit, nick, "", 0, 0, - nil, body, nil, recipients, - ) + for _, peerID := range peerIDs { + s.broker.Notify(peerID) } - for _, ch := range channels { - s.db.PartChannel(ctx, ch.ID, sessionID) //nolint:errcheck,gosec - s.db.DeleteChannelIfEmpty(ctx, ch.ID) //nolint:errcheck,gosec - } - - s.db.DeleteSession(ctx, sessionID) //nolint:errcheck,gosec + return nil } // SetAway sets or clears the away message. Returns true diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 6170f54..f3e3d0b 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -495,6 +495,208 @@ func TestJoinConcurrentWithLastPart(t *testing.T) { } } +// TestJoinConcurrentWithLastQuit checks that a user who +// sends JOIN without the key to a +k channel, again after +// each ERR_BADCHANNELKEY, while the channel's only member +// quits, gets in only once the channel is deleted, into a +// new channel of that name with no key. +func TestJoinConcurrentWithLastQuit(t *testing.T) { + env := newTestEnv(t) + ctx := t.Context() + + for trial := range concurrentJoinTrials { + channel := fmt.Sprintf("#keyed%d", trial) + leaverNick := fmt.Sprintf("leaver%d", trial) + leaverSID := createSession(ctx, t, env.db, leaverNick) + joinerNick := fmt.Sprintf("joiner%d", trial) + joinerSID := createSession(ctx, t, env.db, joinerNick) + + created, err := env.svc.JoinChannel( + ctx, leaverSID, leaverNick, channel, "", + ) + if err != nil { + t.Fatalf("join %s: %v", leaverNick, err) + } + + err = env.db.SetChannelKey(ctx, created.ChannelID, "secret") + if err != nil { + t.Fatalf("set +k: %v", err) + } + + var joined *service.JoinResult + + errs := runConcurrently( + func() error { + env.svc.BroadcastQuit(ctx, leaverSID, leaverNick, "") + + return nil + }, + // The database has one connection, so retried + // JOINs take turns with the QUIT's statements + // and land between any two that are not in one + // transaction. A JOIN after the QUIT gets in, so + // the loop ends. + func() error { + for { + var ( + joinErr error + ircErr *service.IRCError + ) + + joined, joinErr = env.svc.JoinChannel( + ctx, joinerSID, joinerNick, channel, "", + ) + if !errors.As(joinErr, &ircErr) || + ircErr.Code != irc.ErrBadChannelKey { + return joinErr + } + } + }, + ) + if errs[1] != nil { + t.Fatalf("trial %d: JOIN: %v", trial, errs[1]) + } + + key, _ := env.db.GetChannelKey(ctx, joined.ChannelID) + if key != "" { + t.Errorf( + "trial %d: %s got into +k %s without the key", + trial, joinerNick, channel, + ) + } + } +} + +// quitCrowdSize is how many sessions share a second channel +// with the quitting session in +// TestQuitQueuedAheadOfNewNickHoldersJoin. +const quitCrowdSize = 10 + +// TestQuitQueuedAheadOfNewNickHoldersJoin checks that when +// a new session takes a quitting session's nick and joins a +// channel the quitting session shared with a watcher, while +// the QUIT is going out, the watcher has the QUIT queued +// ahead of that JOIN. The quitting session is also on a +// channel with quitCrowdSize other sessions, made before the +// watcher's channel, so that its QUIT reaches the watcher +// last. +func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) { + env := newTestEnv(t) + ctx := t.Context() + + for trial := range concurrentJoinTrials { + nick := fmt.Sprintf("reused%d", trial) + crowd := fmt.Sprintf("#crowd%d", trial) + shared := fmt.Sprintf("#shared%d", trial) + quitterSID := createSession(ctx, t, env.db, nick) + + for _, channel := range []string{crowd, shared} { + _, err := env.svc.JoinChannel(ctx, quitterSID, nick, channel, "") + if err != nil { + t.Fatalf("join %s: %v", channel, err) + } + } + + for member := range quitCrowdSize { + memberNick := fmt.Sprintf("crowd%dx%d", trial, member) + memberSID := createSession(ctx, t, env.db, memberNick) + + _, err := env.svc.JoinChannel(ctx, memberSID, memberNick, crowd, "") + if err != nil { + t.Fatalf("join %s: %v", memberNick, err) + } + } + + watcherNick := fmt.Sprintf("watcher%d", trial) + + watcherSID, watcherClientID, _, err := env.db.CreateSession( + ctx, watcherNick, watcherNick, "localhost", "127.0.0.1", + ) + if err != nil { + t.Fatalf("create session %s: %v", watcherNick, err) + } + + _, err = env.svc.JoinChannel(ctx, watcherSID, watcherNick, shared, "") + if err != nil { + t.Fatalf("join %s: %v", watcherNick, err) + } + + _, lastQueueID, err := env.db.PollMessages(ctx, watcherClientID, 0, 0) + if err != nil { + t.Fatalf("poll: %v", err) + } + + errs := runConcurrently( + func() error { + env.svc.BroadcastQuit(ctx, quitterSID, nick, "") + + return nil + }, + func() error { + return takeNickAndJoin(ctx, env, nick, shared) + }, + ) + if errs[1] != nil { + t.Fatalf("trial %d: new session: %v", trial, errs[1]) + } + + msgs, _, err := env.db.PollMessages( + ctx, watcherClientID, lastQueueID, 0, + ) + if err != nil { + t.Fatalf("poll: %v", err) + } + + commands := commandsFrom(msgs, nick) + if !slices.Equal(commands, []string{irc.CmdQuit, irc.CmdJoin}) { + t.Errorf( + "trial %d: watcher got %v from %s, want QUIT then JOIN", + trial, commands, nick, + ) + } + } +} + +// takeNickAndJoin creates a session with nick, trying again +// while another session holds the nick, and joins it to +// channel. +func takeNickAndJoin( + ctx context.Context, + env *testEnv, + nick, channel string, +) error { + for { + sessionID, _, _, err := env.db.CreateSession( + ctx, nick, nick, "localhost", "127.0.0.1", + ) + if db.IsUniqueConstraintError(err) { + continue + } + + if err != nil { + return err + } + + _, err = env.svc.JoinChannel(ctx, sessionID, nick, channel, "") + + return err + } +} + +// commandsFrom returns the commands of the messages in msgs +// that nick sent, in order. +func commandsFrom(msgs []db.IRCMessage, nick string) []string { + commands := make([]string, 0, len(msgs)) + + for _, msg := range msgs { + if msg.From == nick { + commands = append(commands, msg.Command) + } + } + + return commands +} + // TestJoinUsesUpInvite checks that the JOIN an invite let // into a +i channel uses the invite up, so that a JOIN // after leaving gets ERR_INVITEONLYCHAN. -- 2.54.0 From 3822cdaf1da01c6814305c0d0b975a699d811f80 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 21 Oct 2026 02:48:31 +0200 Subject: [PATCH 29/40] Decode the API's actual fields in neoirc-cli (closes #146) The CLI read the poll cursor from `lastId` while the server sends `last_id`, so the cursor stayed at 0 and every poll returned the whole queue at once. It now reads `last_id`. /names decodes the member objects the server sends and prints each nick with `@` for an operator or `+` for a voiced member. /list prints each channel's name and topic, since the server sends no member count. StateResponse.Channels decodes channel objects. /topic with no text prints a usage line and sends nothing, because the HTTP API has no topic query and a TOPIC without text gets 461. The help says "Set topic". The client tests serve the bodies the handlers send, so a wrong JSON tag in types.go fails a test; another test covers the /names prefixes. Model: opus-5-5 --- internal/cli/api/client.go | 4 +- internal/cli/api/client_test.go | 373 +++++++++++++++++++++++++++++- internal/cli/api/types.go | 25 +- internal/cli/app.go | 38 +-- internal/cli/app_internal_test.go | 51 ++++ 5 files changed, 460 insertions(+), 31 deletions(-) create mode 100644 internal/cli/app_internal_test.go diff --git a/internal/cli/api/client.go b/internal/cli/api/client.go index 5dd5972..91222a8 100644 --- a/internal/cli/api/client.go +++ b/internal/cli/api/client.go @@ -250,7 +250,7 @@ func (client *Client) ListChannels() ( // GetMembers returns members of a channel. func (client *Client) GetMembers( channel string, -) ([]string, error) { +) ([]Member, error) { name := strings.TrimPrefix(channel, "#") data, err := client.do( @@ -263,7 +263,7 @@ func (client *Client) GetMembers( return nil, err } - var members []string + var members []Member err = json.Unmarshal(data, &members) if err != nil { diff --git a/internal/cli/api/client_test.go b/internal/cli/api/client_test.go index 8a42cd1..7a5149c 100644 --- a/internal/cli/api/client_test.go +++ b/internal/cli/api/client_test.go @@ -2,18 +2,28 @@ package neoircapi_test import ( "context" + "encoding/json" + "fmt" "io" "net" "net/http" "net/http/httptest" "net/url" + "reflect" + "slices" + "strconv" "strings" "testing" api "sneak.berlin/go/neoirc/internal/cli/api" + "sneak.berlin/go/neoirc/internal/hashcash" ) -const cookieValue = "opaque-value" +const ( + cookieValue = "opaque-value" + testNick = "alice" + testChannel = "#general" +) // newSessionServer starts a plain-HTTP server that, like // neoircd, sets a Secure auth cookie when a session is @@ -24,10 +34,16 @@ func newSessionServer(t *testing.T) *httptest.Server { mux := http.NewServeMux() + // Without hashcash the server sends no hashcash_bits. mux.HandleFunc("GET /api/v1/server", func( 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( @@ -56,9 +72,14 @@ func newSessionServer(t *testing.T) *httptest.Server { return } - _, _ = io.WriteString( - writer, `{"id":1,"nick":"alice","channels":[]}`, - ) + _, _ = io.WriteString(writer, `{ + "id": 1, + "nick": "alice", + "channels": [ + {"id": 1, "name": "#general", "topic": "Welcome!"}, + {"id": 2, "name": "#dev", "topic": ""} + ] + }`) }) server := httptest.NewServer(mux) @@ -67,6 +88,344 @@ func newSessionServer(t *testing.T) *httptest.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.Validate(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) { t.Parallel() @@ -85,7 +444,7 @@ func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) { "http://" + net.JoinHostPort(host, serverURL.Port()), ) - _, err := client.CreateSession("alice") + _, err := client.CreateSession(testNick) if err != nil { t.Fatalf("create session: %v", err) } @@ -118,7 +477,7 @@ func TestClientWithholdsCookieOverPlainHTTPFromOtherHosts(t *testing.T) { }, } - _, err := client.CreateSession("alice") + _, err := client.CreateSession(testNick) if err != nil { t.Fatalf("create session: %v", err) } diff --git a/internal/cli/api/types.go b/internal/cli/api/types.go index 8c7e50c..e785020 100644 --- a/internal/cli/api/types.go +++ b/internal/cli/api/types.go @@ -16,9 +16,9 @@ type SessionResponse struct { // StateResponse is the response from GET /api/v1/state. type StateResponse struct { - ID int64 `json:"id"` - Nick string `json:"nick"` - Channels []string `json:"channels"` + ID int64 `json:"id"` + Nick string `json:"nick"` + Channels []Channel `json:"channels"` } // Message represents a neoirc message envelope. @@ -53,12 +53,19 @@ func (m *Message) BodyLines() []string { } } -// Channel represents a channel in the list response. +// Channel is a channel as GET /api/v1/channels and +// GET /api/v1/state describe it. type Channel struct { - Name string `json:"name"` - Topic string `json:"topic"` - Members int `json:"members"` - CreatedAt string `json:"createdAt"` + Name string `json:"name"` + Topic string `json:"topic"` +} + +// 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. @@ -72,7 +79,7 @@ type ServerInfo struct { // MessagesResponse wraps polling results. type MessagesResponse struct { Messages []Message `json:"messages"` - LastID int64 `json:"lastId"` + LastID int64 `json:"last_id"` //nolint:tagliatelle } // PollResult wraps the poll response including the cursor. diff --git a/internal/cli/app.go b/internal/cli/app.go index ea53b15..85df5c8 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -434,16 +434,9 @@ func (a *App) cmdTopic(args string) { return } + // The HTTP API has no topic query: a TOPIC needs text. if args == "" { - 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, - )) - } + a.ui.AddStatus("[red]Usage: /topic ") return } @@ -487,12 +480,32 @@ func (a *App) cmdNames() { return } + nicks := make([]string, 0, len(members)) + + for _, member := range members { + nicks = append(nicks, memberPrefix(member)+member.Nick) + } + a.ui.AddLine(target, fmt.Sprintf( "[cyan]*** Members of %s: %s", - target, strings.Join(members, " "), + target, strings.Join(nicks, " "), )) } +// 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() { a.mu.Lock() connected := a.connected @@ -517,8 +530,7 @@ func (a *App) cmdList() { for _, ch := range channels { a.ui.AddStatus(fmt.Sprintf( - " %s (%d members) %s", - ch.Name, ch.Members, ch.Topic, + " %s %s", ch.Name, ch.Topic, )) } @@ -676,7 +688,7 @@ func (a *App) cmdHelp() { " /part [#chan] — Leave channel", " /msg — Send DM", " /query — Open DM window", - " /topic [text] — View/set topic", + " /topic — Set topic", " /names — List channel members", " /list — List channels", " /who [#channel] — List users in channel", diff --git a/internal/cli/app_internal_test.go b/internal/cli/app_internal_test.go new file mode 100644 index 0000000..985a493 --- /dev/null +++ b/internal/cli/app_internal_test.go @@ -0,0 +1,51 @@ +package cli + +import ( + "testing" + + api "sneak.berlin/go/neoirc/internal/cli/api" +) + +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) + } + }) + } +} -- 2.54.0 From e7e408c93e55a51cf826ca7da873bb271e7bbe6e Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 21 Oct 2026 03:31:27 +0200 Subject: [PATCH 30/40] Resolve IRC client hostnames off the accept loop (closes #156) acceptLoop called newConn, which did the reverse DNS lookup, with its three-second timeout, before the loop could accept the next connection, so one client whose address had no answering reverse DNS held up every other IRC connect. serve now does the lookup at its start, on the connection's own goroutine, and the accept loop only accepts. The server keeps the lookup function in a field, as it does the ping times, so that a test can give it one that blocks. The new test checks that a second client registers while the first one's lookup has not returned. Model: opus-5-5 --- internal/ircserver/conn.go | 10 ++++- internal/ircserver/export_test.go | 16 +++++--- internal/ircserver/server.go | 12 +++++- internal/ircserver/server_test.go | 62 ++++++++++++++++++++++++++++++- 4 files changed, 90 insertions(+), 10 deletions(-) diff --git a/internal/ircserver/conn.go b/internal/ircserver/conn.go index e11d09b..bf7c9f4 100644 --- a/internal/ircserver/conn.go +++ b/internal/ircserver/conn.go @@ -70,7 +70,6 @@ type Conn struct { } func newConn( - ctx context.Context, tcpConn net.Conn, log *slog.Logger, database *db.Database, @@ -94,7 +93,6 @@ func newConn( svc: svc, serverSfx: srvName, remoteIP: host, - hostname: resolveHost(ctx, host), } conn.commands = conn.buildCommandMap() @@ -212,12 +210,15 @@ func resolveHost(ctx context.Context, addr string) string { } // 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. // When nothing has been read for interval the client is sent // 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) @@ -226,6 +227,11 @@ func (c *Conn) serve( // 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) }) diff --git a/internal/ircserver/export_test.go b/internal/ircserver/export_test.go index 11f4b8d..69b8b61 100644 --- a/internal/ircserver/export_test.go +++ b/internal/ircserver/export_test.go @@ -15,7 +15,8 @@ import ( // NewTestServer creates a Server suitable for testing, whose // connections use interval and deadline in place of -// pingInterval and pongDeadline. +// pingInterval and pongDeadline, and resolve in place of +// resolveHost. // The caller must call Stop() when finished. func NewTestServer( log *slog.Logger, @@ -23,6 +24,7 @@ func NewTestServer( database *db.Database, brk *broker.Broker, interval, deadline time.Duration, + resolve func(ctx context.Context, addr string) string, ) *Server { globs := &globals.Globals{ Appname: "neoirc", @@ -44,9 +46,16 @@ func NewTestServer( 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. func (s *Server) Start(addr string) error { return s.start(context.Background(), addr) @@ -97,10 +106,7 @@ func NewTestConn( tcpConn net.Conn, nick string, ) *Conn { - conn := newConn( - context.Background(), tcpConn, log, - nil, nil, cfg, nil, - ) + conn := newConn(tcpConn, log, nil, nil, cfg, nil) conn.nick = nick return conn diff --git a/internal/ircserver/server.go b/internal/ircserver/server.go index 3459860..8269934 100644 --- a/internal/ircserver/server.go +++ b/internal/ircserver/server.go @@ -52,6 +52,10 @@ type Server struct { // 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 @@ -75,6 +79,7 @@ func New( pingInterval: pingInterval, pongDeadline: pongDeadline, + resolveHost: resolveHost, } listenAddr := params.Config.IRCListenAddr @@ -175,7 +180,7 @@ func (s *Server) acceptLoop(ctx context.Context) { } client := newConn( - ctx, tcpConn, s.log, + tcpConn, s.log, s.database, s.brk, s.cfg, s.svc, ) @@ -204,7 +209,10 @@ func (s *Server) acceptLoop(ctx context.Context) { s.mu.Unlock() }() - client.serve(ctx, s.pingInterval, s.pongDeadline) + client.serve( + ctx, s.resolveHost, + s.pingInterval, s.pongDeadline, + ) }() } } diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index 5cbe493..b5970af 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -2,6 +2,7 @@ package ircserver_test import ( "bufio" + "context" "crypto/rand" "database/sql" "errors" @@ -11,6 +12,7 @@ import ( "os" "runtime" "strings" + "sync/atomic" "testing" "time" @@ -90,6 +92,21 @@ func newTestEnvWithPingTimes( ) *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( @@ -147,7 +164,7 @@ func newTestEnvWithPingTimes( )) srv := ircserver.NewTestServer( - log, cfg, database, brk, interval, deadline, + log, cfg, database, brk, interval, deadline, resolve, ) err = srv.Start(addr) @@ -415,6 +432,49 @@ func TestRegistration(t *testing.T) { 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. -- 2.54.0 From 0e6d06a7fc6272d447d84246a17d6f2f765fda22 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 21 Oct 2026 04:02:06 +0200 Subject: [PATCH 31/40] Spend a hashcash stamp only when the request succeeds (closes #158) A session request refused with 400 or 409, or a +H channel message refused for membership, a ban or +m, used to spend its stamp, so a retry with the same stamp was refused as already used. hashcash.Validator.Validate is split into Check, which spends nothing, and Claim, which records the stamp; Release undoes a claim. Session creation checks the stamp first, claims it just before CreateSession and releases it if CreateSession fails. SendChannelMessage checks the stamp after the membership, ban and +m checks. Model: opus-5-5 --- README.md | 12 ++- internal/cli/api/client_test.go | 2 +- internal/handlers/api.go | 46 ++++++++++-- internal/handlers/api_test.go | 116 +++++++++++++++++++++++++++++ internal/hashcash/hashcash.go | 62 ++++++++++++--- internal/hashcash/hashcash_test.go | 90 ++++++++++++++++++---- internal/service/service.go | 33 ++++---- internal/service/service_test.go | 83 +++++++++++++++++++++ 8 files changed, 393 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 36caadd..ebd8708 100644 --- a/README.md +++ b/README.md @@ -1404,7 +1404,8 @@ Include the hashcash stamp in the `meta` field: **Server validation:** The server checks that the stamp is well-formed, meets the required difficulty, is bound to the correct channel and message body, has a recent date, and has not been previously used. Spent stamps are cached for 1 -year to prevent replay attacks. +year to prevent replay attacks. The stamp is checked after membership, bans and +`+m`, so a message refused for one of those does not spend it. **Error responses:** If the channel requires hashcash and the stamp is missing, invalid, or replayed, the server returns `ERR_CANNOTSENDTOCHAN (404)` with a @@ -3084,6 +3085,10 @@ limits that punish shared networks. - Stamp has not been used before (replay prevention; the spent stamps are kept in memory, so a restart forgets them) + The stamp is spent only when the session is created. A request refused with + 400 or 409 leaves it unspent, and the client can send the same stamp again + with another nick or username. + ### Stamp Format Standard hashcash format: @@ -3156,12 +3161,13 @@ creating one session pays once and keeps their session. ### Why Hashcash and Not Rate Limits? - **Little state to track**: No IP tables, no token buckets, no sliding windows. - The server verifies a single hash and remembers the stamps already spent. + The server hashes the stamp and remembers the stamps already spent. - **Works through NATs and proxies**: Doesn't punish shared IPs (university campuses, corporate networks, Tor exits). Every client computes their own proof independently. - **Cost falls on the requester**: The server's verification cost is constant - (one SHA-256 hash) regardless of difficulty. Only the client does more work. + regardless of difficulty: at most two SHA-256 hashes of the stamp per session + request, one to check it and one to spend it. Only the client does more work. - **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry. No registration, no email, no phone number, no CAPTCHA. Just compute. - **Language-agnostic**: SHA-256 is available in every programming language. The diff --git a/internal/cli/api/client_test.go b/internal/cli/api/client_test.go index 7a5149c..39a7803 100644 --- a/internal/cli/api/client_test.go +++ b/internal/cli/api/client_test.go @@ -207,7 +207,7 @@ func TestCreateSessionSendsNickAndStamp(t *testing.T) { return } - err = validator.Validate(payload.Hashcash, stampBits) + err = validator.Claim(payload.Hashcash, stampBits) if err != nil { writer.WriteHeader(http.StatusPaymentRequired) diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 5f73c49..b9bf7e2 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -199,7 +199,7 @@ func (hdlr *Handlers) handleCreateSession( return } - if !hdlr.validateHashcash( + if !hdlr.checkHashcash( writer, request, payload.Hashcash, ) { return @@ -232,14 +232,15 @@ func (hdlr *Handlers) handleCreateSession( } hdlr.executeCreateSession( - writer, request, payload.Nick, username, + writer, request, + payload.Nick, username, payload.Hashcash, ) } func (hdlr *Handlers) executeCreateSession( writer http.ResponseWriter, request *http.Request, - nick, username string, + nick, username, stamp string, ) { remoteIP := clientIP(request) @@ -247,12 +248,17 @@ func (hdlr *Handlers) executeCreateSession( request.Context(), remoteIP, ) + if !hdlr.claimHashcash(writer, request, stamp) { + return + } + sessionID, clientID, token, err := hdlr.params.Database.CreateSession( request.Context(), nick, username, hostname, remoteIP, ) if err != nil { + hdlr.hashcashVal.Release(stamp) hdlr.handleCreateSessionError( writer, request, err, ) @@ -273,10 +279,10 @@ func (hdlr *Handlers) executeCreateSession( }, http.StatusCreated) } -// validateHashcash validates a hashcash stamp if required. -// Returns false if validation failed and a response was -// already sent. -func (hdlr *Handlers) validateHashcash( +// checkHashcash checks a hashcash stamp if required, +// without spending it. Returns false if the check failed +// and a response was already sent. +func (hdlr *Handlers) checkHashcash( writer http.ResponseWriter, request *http.Request, stamp string, @@ -295,7 +301,31 @@ func (hdlr *Handlers) validateHashcash( return false } - err := hdlr.hashcashVal.Validate( + err := hdlr.hashcashVal.Check( + 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, ) if err != nil { diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 66985a3..05b8603 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -29,6 +29,7 @@ import ( "golang.org/x/crypto/bcrypt" "modernc.org/sqlite" "sneak.berlin/go/neoirc/internal/broker" + api "sneak.berlin/go/neoirc/internal/cli/api" "sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/globals" @@ -4073,6 +4074,121 @@ func TestChannelHashcashMissingBitsArg(t *testing.T) { } } +// postSessionWithStamp posts a session request for nick and +// username carrying stamp and returns the response status. +// An empty username defaults to the nick. +func postSessionWithStamp( + t *testing.T, + tserver *testServer, + nick, username, stamp string, +) int { + t.Helper() + + body, err := json.Marshal(map[string]string{ + nickKey: nick, "username": username, "pow_token": stamp, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + resp, err := doRequest( + t, http.MethodPost, tserver.url(apiSession), + bytes.NewReader(body), + ) + if err != nil { + t.Fatal(err) + } + + _ = resp.Body.Close() + + return resp.StatusCode +} + +// sessionCount returns the number of sessions in tserver's +// database. +func sessionCount(t *testing.T, tserver *testServer) int64 { + t.Helper() + + count, err := tserver.database.GetUserCount(t.Context()) + if err != nil { + t.Fatalf("count sessions: %v", err) + } + + return count +} + +// TestRefusedSessionLeavesStampUnspent checks that a +// session request is refused with 402 for an invalid stamp +// before its nick is looked at, that a request refused with +// 400 or 409 does not spend its stamp, so the same stamp +// then creates a session, that the stamp stays spent after +// that, that a spent stamp with an invalid nick is refused +// for its nick, and that no refused request creates a +// session. +func TestRefusedSessionLeavesStampUnspent(t *testing.T) { + const ( + bits = 2 + badNick = "bad nick" + ) + + tserver := newTestServerWith(t, bits, "", "") + + // A server with no SERVER_NAME takes stamps for "neoirc". + status := postSessionWithStamp( + t, tserver, "taken", "", api.MintHashcash(bits, "neoirc"), + ) + if status != http.StatusCreated { + t.Fatalf("create taken: want 201, got %d", status) + } + + stamp := api.MintHashcash(bits, "neoirc") + otherServerStamp := api.MintHashcash(bits, "elsewhere") + + steps := []struct { + nick, username, stamp string + want int + }{ + {badNick, "", otherServerStamp, http.StatusPaymentRequired}, + {badNick, "", stamp, http.StatusBadRequest}, + {"fresh", "bad username", stamp, http.StatusBadRequest}, + {"taken", "", stamp, http.StatusConflict}, + {"fresh", "", stamp, http.StatusCreated}, + {"another", "", stamp, http.StatusPaymentRequired}, + // Refused as already spent, the stamp is not given back. + {"later", "", stamp, http.StatusPaymentRequired}, + // Whether the stamp is spent is looked at after the nick. + {badNick, "", stamp, http.StatusBadRequest}, + // The refused request for "another" left the nick free. + {"another", "", api.MintHashcash(bits, "neoirc"), http.StatusCreated}, + } + + sessions := sessionCount(t, tserver) + + for _, step := range steps { + status = postSessionWithStamp( + t, tserver, step.nick, step.username, step.stamp, + ) + if status != step.want { + t.Fatalf( + "nick %q, username %q: want %d, got %d", + step.nick, step.username, step.want, status, + ) + } + + if status == http.StatusCreated { + sessions++ + } + + count := sessionCount(t, tserver) + if count != sessions { + t.Fatalf( + "nick %q, username %q: want %d sessions, got %d", + step.nick, step.username, sessions, count, + ) + } + } +} + func TestNamesShowsHostmask(t *testing.T) { tserver := newTestServer(t) diff --git a/internal/hashcash/hashcash.go b/internal/hashcash/hashcash.go index 2c00725..02f51ad 100644 --- a/internal/hashcash/hashcash.go +++ b/internal/hashcash/hashcash.go @@ -77,9 +77,13 @@ func NewValidator(resource string) *Validator { return validator } -// Validate checks a hashcash stamp. It returns nil if the -// stamp is valid and has not been seen before. -func (v *Validator) Validate( +// Check returns nil if requiredBits is not positive, or if +// the stamp is a well-formed, recently dated proof of work +// for this resource with at least requiredBits. It does not +// 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, requiredBits int, ) error { @@ -87,9 +91,49 @@ func (v *Validator) Validate( 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, ":") if len(parts) != stampFields { - return fmt.Errorf( + return time.Time{}, fmt.Errorf( "%w: expected %d, got %d", errInvalidFields, stampFields, len(parts), ) @@ -104,25 +148,25 @@ func (v *Validator) Validate( version, bitsStr, resource, requiredBits, ) if err != nil { - return err + return time.Time{}, err } stampTime, err := parseStampDate(dateStr) if err != nil { - return err + return time.Time{}, err } err = validateTime(stampTime) if err != nil { - return err + return time.Time{}, err } err = validateProof(stamp, requiredBits) if err != nil { - return err + return time.Time{}, err } - return v.checkAndRecordStamp(stamp, stampTime) + return stampTime, nil } func (v *Validator) validateHeader( diff --git a/internal/hashcash/hashcash_test.go b/internal/hashcash/hashcash_test.go index 8d78383..fcc5dbe 100644 --- a/internal/hashcash/hashcash_test.go +++ b/internal/hashcash/hashcash_test.go @@ -85,7 +85,7 @@ func TestMintAndValidate(t *testing.T) { t, testBits, "test-resource", todayDate(), ) - err := validator.Validate(stamp, testBits) + err := validator.Claim(stamp, testBits) if err != nil { t.Fatalf("valid stamp rejected: %v", err) } @@ -99,17 +99,74 @@ func TestReplayDetection(t *testing.T) { t, testBits, "test-resource", todayDate(), ) - err := validator.Validate(stamp, testBits) + err := validator.Claim(stamp, testBits) if err != nil { t.Fatalf("first use failed: %v", err) } - err = validator.Validate(stamp, testBits) + err = validator.Claim(stamp, testBits) if err == nil { 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) { t.Parallel() @@ -118,7 +175,7 @@ func TestResourceMismatch(t *testing.T) { t, testBits, "wrong-resource", todayDate(), ) - err := validator.Validate(stamp, testBits) + err := validator.Claim(stamp, testBits) if err == nil { t.Fatal("expected resource mismatch error") } @@ -129,7 +186,7 @@ func TestInvalidStampFormat(t *testing.T) { validator := hashcash.NewValidator("test-resource") - err := validator.Validate( + err := validator.Claim( "not:a:valid:stamp", testBits, ) if err == nil { @@ -146,7 +203,7 @@ func TestBadVersion(t *testing.T) { testBits, todayDate(), "test-resource", ) - err := validator.Validate(stamp, testBits) + err := validator.Claim(stamp, testBits) if err == nil { t.Fatal("expected bad version error") } @@ -162,7 +219,7 @@ func TestInsufficientDifficulty(t *testing.T) { todayDate(), "test-resource", ) - err := validator.Validate(stamp, testBits) + err := validator.Claim(stamp, testBits) if err == nil { t.Fatal("expected insufficient bits error") } @@ -178,7 +235,7 @@ func TestExpiredStamp(t *testing.T) { t, testBits, "test-resource", oldDate, ) - err := validator.Validate(stamp, testBits) + err := validator.Claim(stamp, testBits) if err == nil { t.Fatal("expected expired stamp error") } @@ -189,9 +246,14 @@ func TestZeroBitsSkipsValidation(t *testing.T) { validator := hashcash.NewValidator("test-resource") - err := validator.Validate("garbage", 0) + err := validator.Check("garbage", 0) if err != nil { - t.Fatalf("zero bits should skip: %v", err) + t.Fatalf("zero bits should skip the check: %v", err) + } + + err = validator.Claim("garbage", 0) + if err != nil { + t.Fatalf("zero bits should skip the claim: %v", err) } } @@ -204,7 +266,7 @@ func TestLongDateFormat(t *testing.T) { t, testBits, "test-resource", longDate, ) - err := validator.Validate(stamp, testBits) + err := validator.Claim(stamp, testBits) if err != nil { t.Fatalf("long date stamp rejected: %v", err) } @@ -219,7 +281,7 @@ func TestBadDateFormat(t *testing.T) { testBits, "test-resource", ) - err := validator.Validate(stamp, testBits) + err := validator.Claim(stamp, testBits) if err == nil { t.Fatal("expected bad date error") } @@ -235,7 +297,7 @@ func TestMultipleUniqueStamps(t *testing.T) { t, testBits, "test-resource", todayDate(), ) - err := validator.Validate(stamp, testBits) + err := validator.Claim(stamp, testBits) if err != nil { t.Fatalf("unique stamp rejected: %v", err) } @@ -251,7 +313,7 @@ func TestHigherBitsStillValid(t *testing.T) { t, 4, "test-resource", todayDate(), ) - err := validator.Validate(stamp, testBits) + err := validator.Claim(stamp, testBits) if err != nil { t.Fatalf( "higher-difficulty stamp rejected: %v", diff --git a/internal/service/service.go b/internal/service/service.go index ea75f7a..716549f 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -387,13 +387,14 @@ func excludeSession( return out } -// SendChannelMessage validates the hashcash stamp of a -// PRIVMSG to a +H channel, membership and moderation, then -// fans out a message to all channel members except the -// sender. Returns the database row ID, message UUID, and -// any error. The dbID lets callers enqueue the same -// message to the sender when echo is needed (HTTP -// transport). +// SendChannelMessage checks membership, bans and +// moderation, then the hashcash stamp of a PRIVMSG to a +H +// channel, then fans out a message to all channel members +// except the sender. The stamp is checked after the other +// checks so that a message they refuse does not spend it. +// Returns the database row ID, message UUID, and any error. +// The dbID lets callers enqueue the same message to the +// sender when echo is needed (HTTP transport). func (s *Service) SendChannelMessage( ctx context.Context, sessionID int64, @@ -409,15 +410,6 @@ func (s *Service) SendChannelMessage( return 0, "", noSuchChannel } - if command == irc.CmdPrivmsg { - err = s.checkChannelHashcash( - ctx, chID, channel, body, meta, - ) - if err != nil { - return 0, "", err - } - } - isMember, _ := s.db.IsChannelMember( ctx, chID, sessionID, ) @@ -459,6 +451,15 @@ func (s *Service) SendChannelMessage( } } + if command == irc.CmdPrivmsg { + err = s.checkChannelHashcash( + ctx, chID, channel, body, meta, + ) + if err != nil { + return 0, "", err + } + } + memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID) recipients := excludeSession(memberIDs, sessionID) diff --git a/internal/service/service_test.go b/internal/service/service_test.go index f3e3d0b..79ba6bb 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -24,6 +24,7 @@ import ( "sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/globals" + "sneak.berlin/go/neoirc/internal/hashcash" "sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/pkg/irc" @@ -1056,6 +1057,88 @@ func TestSendChannelMessage_Moderated(t *testing.T) { } } +// TestRefusedChannelMessageLeavesStampUnspent checks that a +// PRIVMSG to a +H channel refused because the sender is not +// a member, is banned, or has no voice on a +m channel does +// not spend its hashcash stamp: the operator can then send +// the same message with it. +func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) { + const ( + channel = "#paid" + bits = 2 + ) + + refusals := []struct { + name string + // refuse stops bob from sending to the channel. + refuse func(ctx context.Context, env *testEnv, chID, bobSID int64) error + }{ + {"not a member", func(ctx context.Context, env *testEnv, _, bobSID int64) error { + return env.svc.PartChannel(ctx, bobSID, "bob", channel, "") + }}, + {"banned", func(ctx context.Context, env *testEnv, chID, _ int64) error { + return env.db.AddChannelBan(ctx, chID, "bob!*@*", "alice") + }}, + {"moderated", func(ctx context.Context, env *testEnv, chID, _ int64) error { + return env.svc.SetChannelFlag(ctx, chID, 'm', true) + }}, + } + + body, _ := json.Marshal([]string{"hello"}) //nolint:errchkjson + + for _, refusal := range refusals { + t.Run(refusal.name, func(t *testing.T) { + env := newTestEnv(t) + ctx := t.Context() + + aliceSID := createSession(ctx, t, env.db, "alice") + bobSID := createSession(ctx, t, env.db, "bob") + + joined, err := env.svc.JoinChannel(ctx, aliceSID, "alice", channel, "") + if err != nil { + t.Fatalf("join alice: %v", err) + } + + _, err = env.svc.JoinChannel(ctx, bobSID, "bob", channel, "") + if err != nil { + t.Fatalf("join bob: %v", err) + } + + err = env.db.SetChannelHashcashBits(ctx, joined.ChannelID, bits) + if err != nil { + t.Fatalf("set +H: %v", err) + } + + err = refusal.refuse(ctx, env, joined.ChannelID, bobSID) + if err != nil { + t.Fatalf("refuse bob: %v", err) + } + + stamp := hashcash.MintChannelStamp( + bits, channel, hashcash.BodyHash(body), + ) + meta, _ := json.Marshal(map[string]string{"hashcash": stamp}) + + _, _, err = env.svc.SendChannelMessage( + ctx, bobSID, "bob", irc.CmdPrivmsg, channel, body, meta, + ) + + var ircErr *service.IRCError + if !errors.As(err, &ircErr) || + ircErr.Code != irc.ErrCannotSendToChan { + t.Fatalf("bob: want ERR_CANNOTSENDTOCHAN, got %v", err) + } + + _, _, err = env.svc.SendChannelMessage( + ctx, aliceSID, "alice", irc.CmdPrivmsg, channel, body, meta, + ) + if err != nil { + t.Fatalf("alice with the same stamp: %v", err) + } + }) + } +} + func TestQueryUserMode(t *testing.T) { env := newTestEnv(t) ctx := t.Context() -- 2.54.0 From c267cd215ce3e03e1d01cba006061ebc7f068a76 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 21 Oct 2026 05:02:00 +0200 Subject: [PATCH 32/40] Refuse passwords over 72 bytes and confirm only stored ones (closes #144) bcrypt cannot hash a password longer than 72 bytes. PASS over the HTTP API answered one with a 500, and the IRC listener confirmed it without storing it. Both transports now refuse a password outside 8 to 72 bytes with the reply a short one already got: 461 over the HTTP API, a NOTICE over the IRC listener. setPassword returns its error, and "Password set" is sent only when the password was stored. A PASS given before registration, an empty one included, is checked after the welcome burst and gets the same NOTICE. Model: opus-5-5 --- README.md | 17 ++-- internal/handlers/api_test.go | 85 +++++++++++----- internal/handlers/auth.go | 12 ++- internal/ircserver/commands.go | 11 +-- internal/ircserver/conn.go | 42 ++++++-- internal/ircserver/server_test.go | 159 ++++++++++++++++++++++++++++++ 6 files changed, 280 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index ebd8708..b94af90 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ For users who want to access the same session from multiple devices: - **Set password via IRC PASS command**: the authenticated client sends `POST /api/v1/messages` with `{"command":"PASS","body":["mypassword"]}`. The server hashes the password with bcrypt and stores it on the session. Password - must be at least 8 characters. + must be 8 to 72 bytes. - **Login from another client**: `POST /api/v1/login` with nick and password → server verifies the password, creates a new client for the existing session, and sets an auth cookie. Channel memberships are shared; the new client gets @@ -835,10 +835,11 @@ Set a password on the current session, enabling multi-client login via **Behavior:** -- `body[0]` is the password. Must be at least 8 characters. +- `body[0]` is the password. Must be 8 to 72 bytes; bcrypt cannot hash a longer + one. - On success, the server responds with `{"status": "ok"}`. -- If the password is too short or missing, the server sends ERR_NEEDMOREPARAMS - (461) via the message queue. +- If the password is too short, too long or missing, the server sends + ERR_NEEDMOREPARAMS (461) via the message queue. - Calling PASS again overwrites the previous password. - Once a password is set, `POST /api/v1/login` can be used with the nick and password to create additional clients on the same session. @@ -2678,9 +2679,11 @@ IRC_LISTEN_ADDR: "" - **Wire format**: CR-LF delimited lines, max 512 bytes per line - **Connection registration**: Clients must send `NICK` and `USER` to register. - An optional `PASS` before registration sets the session password (minimum 8 - characters). Registering creates a session without a hashcash stamp; closing - the connection ends the session, as `QUIT` does. + An optional `PASS` before registration sets the session password (8 to 72 + bytes); one outside that range is refused with a `NOTICE` once registration + completes, as `PASS` after registration is. Registering creates a session + without a hashcash stamp; closing the connection ends the session, as `QUIT` + does. - **PING**: a connection that sends nothing for 90 seconds is sent a `PING`, and is closed if it sends nothing in the 30 seconds after that; any line, such as the client's `PONG`, starts both waits again. diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 05b8603..060f3dd 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -2906,32 +2906,73 @@ func TestPassCommand(t *testing.T) { } } -func TestPassCommandShortPassword(t *testing.T) { - tserver := newTestServer(t) - token := tserver.createSession("shortpw") +// The shortest and longest password PASS accepts; bcrypt +// cannot hash more than 72 bytes. +const ( + minPasswordBytes = 8 + maxPasswordBytes = 72 +) - // Drain initial messages. - _, lastID := tserver.pollMessages(token, 0) - - // Try short password — should fail. - status, _ := tserver.sendCommand( - token, - map[string]any{ - commandKey: irc.CmdPass, - bodyKey: []string{"short"}, - }, - ) - if status != http.StatusOK { - t.Fatalf("expected 200, got %d", status) +// TestPassCommandLength checks that PASS stores a password of +// 8 to 72 bytes and refuses any other with 461. The limit +// counts bytes: "é" is two bytes in UTF-8, so 72 of them are +// refused. +func TestPassCommandLength(t *testing.T) { + cases := []struct { + name string + password string + stored bool + }{ + {"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}, } - msgs, _ := tserver.pollMessages(token, lastID) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("pwlength") - if !findNumeric(msgs, "461") { - t.Fatalf( - "expected ERR_NEEDMOREPARAMS (461), got %v", - msgs, - ) + _, lastID := tserver.pollMessages(token, 0) + + status, result := tserver.sendCommand( + token, + map[string]any{ + commandKey: irc.CmdPass, + bodyKey: []string{tc.password}, + }, + ) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d: %v", status, result) + } + + if !tc.stored { + msgs, _ := tserver.pollMessages(token, lastID) + if !findNumeric(msgs, "461") { + t.Fatalf( + "expected ERR_NEEDMOREPARAMS (461), got %v", + msgs, + ) + } + + return + } + + if result[statusKey] != "ok" { + t.Fatalf("expected ok, got %v", result) + } + + postJSONExpectStatus( + t, tserver, "/api/v1/login", + map[string]string{ + nickKey: "pwlength", passwordKey: tc.password, + }, + http.StatusOK, + ) + }) } } diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 7104c13..21d9bc6 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -8,7 +8,12 @@ import ( "sneak.berlin/go/neoirc/pkg/irc" ) -const minPasswordLength = 8 +// A session password is 8 to 72 bytes; bcrypt cannot hash +// a longer one. +const ( + minPasswordLength = 8 + maxPasswordLength = 72 +) // HandleLogin authenticates a user with nick and password. func (hdlr *Handlers) HandleLogin() http.HandlerFunc { @@ -149,12 +154,13 @@ func (hdlr *Handlers) handlePass( password := lines[0] - if len(password) < minPasswordLength { + if len(password) < minPasswordLength || + len(password) > maxPasswordLength { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdPass}, - "Password must be at least 8 characters", + "Password must be 8 to 72 bytes", ) return diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index 960dac7..77d1eb3 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -1216,15 +1216,14 @@ func (c *Conn) handlePassPostReg( } password := msg.Params[0] - if len(password) < minPasswordLen { - c.sendFromServer("NOTICE", c.nick, - "Password must be at least 8 characters", - ) - + if !c.checkPasswordLength(password) { return } - c.setPassword(ctx, password) + err := c.setPassword(ctx, password) + if err != nil { + return + } c.sendFromServer("NOTICE", c.nick, "Password set. You can reconnect using "+ diff --git a/internal/ircserver/conn.go b/internal/ircserver/conn.go index bf7c9f4..dbbd0e5 100644 --- a/internal/ircserver/conn.go +++ b/internal/ircserver/conn.go @@ -27,6 +27,7 @@ const ( pingInterval = 90 * time.Second pongDeadline = 30 * time.Second minPasswordLen = 8 + maxPasswordLen = 72 // bcrypt cannot hash a longer one maxHashcashBits = 40 ) @@ -62,6 +63,7 @@ type Conn struct { registered bool gotNick bool gotUser bool + gotPass bool passWord string lastQueueID int64 @@ -436,6 +438,7 @@ func (c *Conn) handlePreRegistration( } c.passWord = msg.Params[0] + c.gotPass = true case irc.CmdNick: if len(msg.Params) < 1 { c.sendNumeric( @@ -550,17 +553,20 @@ func (c *Conn) completeRegistration(ctx context.Context) { // So that KILL, from either transport, can close it. 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. c.deliverWelcome() c.deliverLusers(ctx) 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. go c.relayMessages(ctx) } @@ -643,13 +649,33 @@ func (c *Conn) deliverMOTD() { ) } -// setPassword sets a bcrypt password on the session. -func (c *Conn) setPassword(ctx context.Context, pw string) { +// checkPasswordLength reports whether pw is 8 to 72 bytes +// long, and sends the client a NOTICE saying so when it is +// not. +func (c *Conn) checkPasswordLength(pw string) bool { + if len(pw) >= minPasswordLen && len(pw) <= maxPasswordLen { + return true + } + + c.sendFromServer("NOTICE", c.nick, + "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. err := c.database.SetPassword(ctx, c.sessionID, pw) if err != nil { c.log.Error( "failed to set password", "error", err, ) + + return err } + + return nil } diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index b5970af..3b7eabf 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -1158,6 +1158,165 @@ 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) { t.Parallel() -- 2.54.0 From 24b9b58cac67e48d949435152bdd49578f965757 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 21 Oct 2026 05:31:24 +0200 Subject: [PATCH 33/40] Register the broker wait before the first poll (closes #157) GET /api/v1/messages read the client output queue and only then registered with the broker. Notify wakes only the channels registered at that moment, so a message queued between the empty read and the registration waited out the whole long-poll timeout. The handler now registers first and removes its channel on every exit path; the IRC relay loop registers before each drain in the same way. The handlers read the queue through a function field that defaults to Database.PollMessages, so tests can act just after a read: one checks that a message queued there comes back at once, others that no wait channel stays registered however a request ends. Model: opus-5-5 --- internal/handlers/api.go | 26 ++- internal/handlers/api_test.go | 363 ++++++++++++++++++++++++++++++- internal/handlers/export_test.go | 19 ++ internal/handlers/handlers.go | 11 + internal/ircserver/relay.go | 9 +- internal/ircserver/relay_test.go | 49 +++-- 6 files changed, 451 insertions(+), 26 deletions(-) create mode 100644 internal/handlers/export_test.go diff --git a/internal/handlers/api.go b/internal/handlers/api.go index b9bf7e2..f6f6873 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -813,7 +813,14 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc { timeout = maxLongPollTimeout } - msgs, lastQID, err := hdlr.params.Database.PollMessages( + // Register with the broker before reading the queue. + // 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, afterID, pollMessageLimit, ) @@ -841,19 +848,22 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc { hdlr.longPoll( writer, request, - sessionID, clientID, afterID, timeout, + waitCh, 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( writer http.ResponseWriter, request *http.Request, - sessionID, clientID, afterID int64, + waitCh <-chan struct{}, + clientID, afterID int64, timeout int, ) { - waitCh := hdlr.broker.Wait(sessionID) - timer := time.NewTimer( time.Duration(timeout) * time.Second, ) @@ -864,14 +874,10 @@ func (hdlr *Handlers) longPoll( case <-waitCh: case <-timer.C: case <-request.Context().Done(): - hdlr.broker.Remove(sessionID, waitCh) - return } - hdlr.broker.Remove(sessionID, waitCh) - - msgs, lastQID, err := hdlr.params.Database.PollMessages( + msgs, lastQID, err := hdlr.pollMessages( request.Context(), clientID, afterID, pollMessageLimit, ) diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 060f3dd..4d5cb9c 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -140,6 +140,7 @@ type testServer struct { fxApp *fxtest.App broker *broker.Broker database *db.Database + handlers *handlers.Handlers } func newTestServer( @@ -170,6 +171,7 @@ func newTestServerWith( srv *server.Server brk *broker.Broker database *db.Database + hdlr *handlers.Handlers ) app := fxtest.New(t, @@ -210,7 +212,7 @@ func newTestServerWith( newTestHandlers, newTestServerFx, ), - fx.Populate(&srv, &brk, &database), + fx.Populate(&srv, &brk, &database, &hdlr), ) app.RequireStart() @@ -228,6 +230,7 @@ func newTestServerWith( fxApp: app, broker: brk, database: database, + handlers: hdlr, } } @@ -2554,6 +2557,364 @@ func TestLongPollTimeout(t *testing.T) { } } +// TestLongPollReturnsMessageQueuedAfterFirstRead checks that +// a message queued, and the broker notified, after the long +// poll's first read of the queue found nothing but before the +// poll waits, is returned at once and not when the poll times +// out, and that the poll then holds no broker wait channel. +func TestLongPollReturnsMessageQueuedAfterFirstRead(t *testing.T) { + const ( + nick = "lp_late" + sender = "lp_late_sender" + + // pollTimeout is the long poll's timeout, in seconds. + pollTimeout = 10 + + // atOnce is the longest the long poll may take to + // return the message. + atOnce = 5 * time.Second + ) + + tserver := newTestServer(t) + token := tserver.createSession(nick) + + _, lastID := tserver.pollMessages(token, 0) + + sessionID, err := tserver.database.GetSessionByNick(t.Context(), nick) + if err != nil { + t.Fatalf("get session of %s: %v", nick, err) + } + + queued := make(chan error, 1) + + var once sync.Once + + tserver.handlers.SetPollMessages(func( + ctx context.Context, + clientID, afterQueueID int64, + limit int, + ) ([]db.IRCMessage, int64, error) { + msgs, lastQID, pollErr := tserver.database.PollMessages( + ctx, clientID, afterQueueID, limit, + ) + + once.Do(func() { + queued <- tserver.queueMessage(ctx, sessionID, sender, nick) + }) + + return msgs, lastQID, pollErr + }) + + start := time.Now() + + msgs, err := tserver.longPoll(token, lastID, pollTimeout) + if err != nil { + t.Fatalf("long poll: %v", err) + } + + elapsed := time.Since(start) + + select { + case queueErr := <-queued: + if queueErr != nil { + t.Fatalf("queue message: %v", queueErr) + } + default: + t.Fatal("the long poll did not read the queue") + } + + if elapsed > atOnce { + t.Fatalf("the long poll returned after %v, want within %v", + elapsed, atOnce) + } + + if !findMessage(msgs, privmsgCmd, sender) { + t.Fatalf("long poll: got %v, want the PRIVMSG from %s", + msgs, sender) + } + + tserver.checkWaitChannelRemoved(sessionID) +} + +// errReadFailed is what a test's replacement for +// Database.PollMessages returns to make a read of the queue +// fail. +var errReadFailed = errors.New("read failed") + +// TestGetMessagesRemovesWaitChannel checks that a request to +// GET /api/v1/messages leaves no broker wait channel behind, +// whether it is answered at once, after its timeout, or with +// an error because a read of the queue failed. +func TestGetMessagesRemovesWaitChannel(t *testing.T) { + const ( + // longTimeout, in seconds, is the timeout of the + // requests that must be answered without waiting. + longTimeout = 10 + + // shortTimeout, in seconds, is the timeout of the + // requests that wait it out. + shortTimeout = 1 + ) + + // queued is whether a message is queued before the request, + // whose timeout is in seconds. failingRead is as for + // getMessagesFailingRead. wantReads is how many times the + // handler reads the queue: once if it answers at once, + // twice if it waits first. + tests := []struct { + name string + queued bool + timeout, failingRead int + wantReads, wantStatus int + }{ + { + name: "nothing queued, timeout 0", timeout: 0, + wantReads: 1, wantStatus: http.StatusOK, + }, + { + name: "message queued", queued: true, timeout: longTimeout, + wantReads: 1, wantStatus: http.StatusOK, + }, + { + name: "timed out", timeout: shortTimeout, + wantReads: 2, wantStatus: http.StatusOK, + }, + { + name: "first read failed", timeout: longTimeout, failingRead: 1, + wantReads: 1, wantStatus: http.StatusInternalServerError, + }, + { + name: "read after the wait failed", timeout: shortTimeout, + failingRead: 2, wantReads: 2, + wantStatus: http.StatusInternalServerError, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + const nick = "wc_poller" + + tserver := newTestServer(t) + token := tserver.createSession(nick) + + _, lastID := tserver.pollMessages(token, 0) + + sessionID, err := tserver.database.GetSessionByNick( + t.Context(), nick, + ) + if err != nil { + t.Fatalf("get session of %s: %v", nick, err) + } + + if tc.queued { + err = tserver.queueMessage( + t.Context(), sessionID, "wc_sender", nick, + ) + if err != nil { + t.Fatalf("queue message: %v", err) + } + } + + status, reads := tserver.getMessagesFailingRead( + token, lastID, tc.timeout, tc.failingRead, + ) + if status != tc.wantStatus { + t.Errorf("status %d, want %d", status, tc.wantStatus) + } + + if reads != tc.wantReads { + t.Errorf("the handler read the queue %d times, want %d", + reads, tc.wantReads) + } + + tserver.checkWaitChannelRemoved(sessionID) + }) + } +} + +// getMessagesFailingRead sends GET /api/v1/messages for the +// client token with timeout and after afterID. The handler's +// read of the queue numbered failingRead, counting from 1, +// fails; with 0, none does. It returns the response status +// and how many times the handler read the queue. +func (tserver *testServer) getMessagesFailingRead( + token string, + afterID int64, + timeout, failingRead int, +) (int, int) { + tserver.t.Helper() + + reads := 0 + + tserver.handlers.SetPollMessages(func( + ctx context.Context, + clientID, afterQueueID int64, + limit int, + ) ([]db.IRCMessage, int64, error) { + reads++ + + if reads == failingRead { + return nil, 0, errReadFailed + } + + return tserver.database.PollMessages( + ctx, clientID, afterQueueID, limit, + ) + }) + + resp, err := doRequestAuth( + tserver.t, http.MethodGet, + tserver.url(fmt.Sprintf( + "%s?timeout=%d&after=%d", apiMessages, timeout, afterID, + )), + token, nil, + ) + if err != nil { + tserver.t.Fatalf("get messages: %v", err) + } + + _ = resp.Body.Close() + + return resp.StatusCode, reads +} + +// TestLongPollRemovesWaitChannelWhenClientGivesUp checks that +// a long poll whose client gives up while it waits leaves no +// broker wait channel behind. +func TestLongPollRemovesWaitChannelWhenClientGivesUp(t *testing.T) { + const ( + nick = "wc_leaver" + + // pollTimeout is the long poll's timeout, in seconds. + pollTimeout = 10 + + // readDeadline is how long the handler has to read + // the queue. + readDeadline = 5 * time.Second + ) + + tserver := newTestServer(t) + token := tserver.createSession(nick) + + _, lastID := tserver.pollMessages(token, 0) + + sessionID, err := tserver.database.GetSessionByNick(t.Context(), nick) + if err != nil { + t.Fatalf("get session of %s: %v", nick, err) + } + + read := make(chan struct{}) + + var once sync.Once + + tserver.handlers.SetPollMessages(func( + ctx context.Context, + clientID, afterQueueID int64, + limit int, + ) ([]db.IRCMessage, int64, error) { + msgs, lastQID, pollErr := tserver.database.PollMessages( + ctx, clientID, afterQueueID, limit, + ) + + once.Do(func() { close(read) }) + + return msgs, lastQID, pollErr + }) + + ctx, giveUp := context.WithCancel(t.Context()) + defer giveUp() + + request, err := http.NewRequestWithContext( + ctx, http.MethodGet, + tserver.url(fmt.Sprintf( + "%s?timeout=%d&after=%d", apiMessages, pollTimeout, lastID, + )), + nil, + ) + if err != nil { + t.Fatalf("new request: %v", err) + } + + request.AddCookie(&http.Cookie{ //nolint:exhaustruct,gosec // only name+value + Name: authCookieName, + Value: token, + }) + + done := make(chan struct{}) + + go func() { + defer close(done) + + resp, doErr := http.DefaultClient.Do(request) + if doErr == nil { + _ = resp.Body.Close() + } + }() + + select { + case <-read: + case <-time.After(readDeadline): + t.Fatal("the long poll did not read the queue") + } + + giveUp() + <-done + + tserver.checkWaitChannelRemoved(sessionID) +} + +// checkWaitChannelRemoved fails the test unless the broker +// holds no wait channel for the session sessionID. A client +// can have its response before the handler has returned, and +// a handler notices a client that gives up only later, so it +// waits up to removeDeadline for that. +func (tserver *testServer) checkWaitChannelRemoved(sessionID int64) { + tserver.t.Helper() + + const ( + removeDeadline = 5 * time.Second + checkStep = 10 * time.Millisecond + ) + + deadline := time.Now().Add(removeDeadline) + + for tserver.broker.ListenerCount(sessionID) != 0 { + if time.Now().After(deadline) { + tserver.t.Fatalf("got %d broker wait channels, want 0", + tserver.broker.ListenerCount(sessionID)) + } + + time.Sleep(checkStep) + } +} + +// queueMessage stores a PRIVMSG from sender to nick, queues +// it to the session sessionID and notifies the broker, as the +// server does for a DM. It returns errors instead of failing +// the test, so a handler's goroutine can call it. +func (tserver *testServer) queueMessage( + ctx context.Context, + sessionID int64, + sender, nick string, +) error { + msgID, _, err := tserver.database.InsertMessage( + ctx, privmsgCmd, sender, nick, sessionID, 0, + nil, json.RawMessage(`["hello"]`), nil, + ) + if err != nil { + return fmt.Errorf("insert message: %w", err) + } + + err = tserver.database.EnqueueToSession(ctx, sessionID, msgID) + if err != nil { + return fmt.Errorf("enqueue: %w", err) + } + + tserver.broker.Notify(sessionID) + + return nil +} + func TestEphemeralChannelCleanup(t *testing.T) { const channel = "#ephemeral" diff --git a/internal/handlers/export_test.go b/internal/handlers/export_test.go new file mode 100644 index 0000000..67d1b65 --- /dev/null +++ b/internal/handlers/export_test.go @@ -0,0 +1,19 @@ +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 +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 690b045..2326fff 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -64,6 +64,16 @@ type Handlers struct { loginLimiter *ratelimit.Limiter 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. @@ -95,6 +105,7 @@ func New( hashcashVal: hashcash.NewValidator(resource), loginLimiter: ratelimit.New(loginRate, loginBurst), stats: params.Stats, + pollMessages: params.Database.PollMessages, } lifecycle.Append(fx.Hook{ diff --git a/internal/ircserver/relay.go b/internal/ircserver/relay.go index bbb5309..c568133 100644 --- a/internal/ircserver/relay.go +++ b/internal/ircserver/relay.go @@ -27,17 +27,22 @@ func (c *Conn) relayMessages(ctx context.Context) { 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. delivered := c.drainQueue(ctx) if delivered { + c.brk.Remove(c.sessionID, waitCh) + // Tight loop while there are messages. continue } // Wait for notification or timeout. - waitCh := c.brk.Wait(c.sessionID) - select { case <-waitCh: // New message notification — loop back. diff --git a/internal/ircserver/relay_test.go b/internal/ircserver/relay_test.go index 767fb69..80d2ace 100644 --- a/internal/ircserver/relay_test.go +++ b/internal/ircserver/relay_test.go @@ -47,14 +47,13 @@ func (env *testEnv) queueMessage( env.brk.Notify(sessionID) } -// TestRelayKeepsOneWaitChannel checks that an idle IRC -// listener connection holds at most one broker wait channel -// across several relay ticks, and none once it closes. +// 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() - const idleTicks = 5 - env := newTestEnv(t) client := env.dial(t) @@ -65,15 +64,17 @@ func TestRelayKeepsOneWaitChannel(t *testing.T) { t.Fatalf("get session of idle: %v", err) } - for tick := range idleTicks { - time.Sleep(ircserver.PollInterval) + env.checkOneWaitChannel(t, sessionID) - got := env.brk.ListenerCount(sessionID) - if got > 1 { - t.Fatalf("tick %d: got %d wait channels, want at most 1", - tick+1, got) - } - } + 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 { @@ -92,6 +93,28 @@ func TestRelayKeepsOneWaitChannel(t *testing.T) { } } +// 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. -- 2.54.0 From d37c130ee868af15fb406c057ef0f4ec4af2962e Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 21 Oct 2026 05:48:33 +0200 Subject: [PATCH 34/40] Bring the web client in line with the current API (closes #145) The web client was written against an older shape of the API. MODE now carries its change in `body`, so /mode sets modes; /mode alone queries them and the 324 reply is shown. Numeric replies are matched on `code`, so /list, /who and /whois print their lines, and 353 names are read as hostmasks. The member list takes @ and + from `isOperator` and `isVoiced`. A 401 on the poll reloads the page, which shows the login screen, where it used to retry for ever. The token code is gone: no Authorization header, nothing stored, and the page always asks /state whether it can resume a session. Model: opus-5-5 --- web/src/app.jsx | 153 ++++++++++++++++++++++++++++++------------------ 1 file changed, 95 insertions(+), 58 deletions(-) diff --git a/web/src/app.jsx b/web/src/app.jsx index 2bea255..932ee72 100644 --- a/web/src/app.jsx +++ b/web/src/app.jsx @@ -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 = {}) { - const token = localStorage.getItem("neoirc_token"); const headers = { "Content-Type": "application/json", ...(opts.headers || {}), }; - if (token) headers["Authorization"] = `Bearer ${token}`; const { signal, ...rest } = opts; return fetch(API + path, { ...rest, headers, signal }).then(async (r) => { @@ -122,12 +122,11 @@ function LoginScreen({ onLogin }) { if (s.name) hashcashResourceRef.current = s.name; }) .catch(() => {}); - const saved = localStorage.getItem("neoirc_token"); - if (saved) { - api("/state?initChannelState=1") - .then((u) => onLogin(u.nick, true)) - .catch(() => localStorage.removeItem("neoirc_token")); - } + // Resume the session if the browser still holds a valid auth + // cookie; a 401 leaves the login form up. + api("/state?initChannelState=1") + .then((u) => onLogin(u.nick, true)) + .catch(() => {}); inputRef.current?.focus(); }, []); @@ -152,7 +151,6 @@ function LoginScreen({ onLogin }) { method: "POST", body: JSON.stringify(reqBody), }); - localStorage.setItem("neoirc_token", res.token); onLogin(res.nick); } catch (err) { setError(err.data?.error || "Connection failed"); @@ -227,10 +225,9 @@ function UserList({ members, onNickClick }) { const regular = []; for (const m of members) { - const mode = m.mode || ""; - if (mode === "o") { + if (m.isOperator) { ops.push(m); - } else if (mode === "v") { + } else if (m.isVoiced) { voiced.push(m); } else { regular.push(m); @@ -366,7 +363,9 @@ function App() { command: msg.command, }; - switch (msg.command) { + // A numeric reply carries its number in `code` and its name in + // `command`; other messages have no `code`. + switch (msg.code || msg.command) { case "PRIVMSG": case "NOTICE": { let text = body; @@ -464,8 +463,9 @@ function App() { break; } - case "353": { - // RPL_NAMREPLY — parse mode prefixes from names list. + case 353: { + // RPL_NAMREPLY — each name is a hostmask (nick!user@host), + // prefixed with @ for an operator or + for a voiced member. if ( Array.isArray(msg.params) && msg.params.length >= 2 && @@ -477,21 +477,18 @@ function App() { : String(msg.body); const names = namesStr.split(/\s+/).filter(Boolean); const parsed = names.map((n) => { - if (n.startsWith("@")) { - return { nick: n.slice(1), mode: "o" }; - } - if (n.startsWith("+")) { - return { nick: n.slice(1), mode: "v" }; - } + const isOperator = n.startsWith("@"); + const isVoiced = n.startsWith("+"); + const hostmask = isOperator || isVoiced ? n.slice(1) : n; - return { nick: n, mode: "" }; + return { nick: hostmask.split("!")[0], isOperator, isVoiced }; }); setMembers((prev) => ({ ...prev, [channel]: parsed })); } break; } - case "332": { + case 332: { // RPL_TOPIC — set topic from server. if (Array.isArray(msg.params) && msg.params.length >= 1) { const channel = msg.params[0]; @@ -503,7 +500,7 @@ function App() { break; } - case "322": { + case 322: { // RPL_LIST — channel, member count, topic. if (Array.isArray(msg.params) && msg.params.length >= 2) { const chName = msg.params[0]; @@ -518,7 +515,7 @@ function App() { break; } - case "323": + case 323: addMessage("Server", { ...base, text: body || "End of channel list", @@ -526,7 +523,7 @@ function App() { }); break; - case "352": { + case 352: { // RPL_WHOREPLY — channel, user, host, server, nick, flags. if (Array.isArray(msg.params) && msg.params.length >= 5) { const whoCh = msg.params[0]; @@ -541,7 +538,7 @@ function App() { break; } - case "315": + case 315: addMessage("Server", { ...base, text: body || "End of /WHO list", @@ -549,20 +546,20 @@ function App() { }); break; - case "311": { - // RPL_WHOISUSER — nick, user, host, *, realname. - if (Array.isArray(msg.params) && msg.params.length >= 1) { - const wiNick = msg.params[0]; + case 311: { + // RPL_WHOISUSER — params nick, user, host, *; body realname. + if (Array.isArray(msg.params) && msg.params.length >= 3) { + const [wiNick, wiUser, wiHost] = msg.params; addMessage("Server", { ...base, - text: `${wiNick} (${body})`, + text: `${wiNick} is ${wiUser}@${wiHost} (${body})`, system: true, }); } break; } - case "312": { + case 312: { // RPL_WHOISSERVER — nick, server, server info. if (Array.isArray(msg.params) && msg.params.length >= 2) { const wiNick = msg.params[0]; @@ -576,7 +573,33 @@ function App() { break; } - case "319": { + case 313: { + // 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. if (Array.isArray(msg.params) && msg.params.length >= 1) { const wiNick = msg.params[0]; @@ -589,7 +612,7 @@ function App() { break; } - case "318": + case 318: addMessage("Server", { ...base, text: body || "End of /WHOIS list", @@ -597,9 +620,21 @@ function App() { }); break; - case "375": - case "372": - case "376": + case 324: { + // RPL_CHANNELMODEIS — channel, mode string. + if (Array.isArray(msg.params) && msg.params.length >= 2) { + 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 }); break; @@ -637,6 +672,14 @@ function App() { } catch (err) { if (!alive) break; 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); await new Promise((r) => setTimeout(r, RECONNECT_DELAY)); } @@ -972,27 +1015,22 @@ function App() { 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); - if (modeArgs.length > 0) { - try { - await api("/messages", { - method: "POST", - body: JSON.stringify({ - command: "MODE", - to: tab.name, - params: modeArgs, - }), - }); - } catch (err) { - addSystemMessage( - "Server", - `Mode change failed: ${err.data?.error || "error"}`, - ); - } - } else { + const body = + modeArgs.length > 0 + ? { command: "MODE", to: tab.name, body: modeArgs } + : { command: "MODE", to: tab.name }; + try { + await api("/messages", { + method: "POST", + body: JSON.stringify(body), + }); + } catch (err) { addSystemMessage( "Server", - "Usage: /mode <+/-mode> [params]", + `MODE failed: ${err.data?.error || "error"}`, ); } @@ -1009,7 +1047,6 @@ function App() { } catch (e) { // Ignore. } - localStorage.removeItem("neoirc_token"); localStorage.removeItem("neoirc_channels"); window.location.reload(); @@ -1128,7 +1165,7 @@ function App() { " /me action — Send an action", " /nick newnick — Change your nickname", " /topic [text] — View or set channel topic", - " /mode +/-flags — Set channel modes", + " /mode [+/-flag [arg]] — View or set channel modes", " /motd — Display the message of the day", " /list — List all channels", " /who [#channel] — List users in a channel", -- 2.54.0 From 3cbdac7bdaffb77a8fc1f224e6adb285e0f71df6 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 21 Oct 2026 06:14:42 +0200 Subject: [PATCH 35/40] Send an IRC listener user their own TOPIC, MODE and KICK once (closes #143) The service sends a TOPIC, MODE or KICK to every channel member, the issuer included, and the IRC listener also echoes it to the issuer's connection, so an IRC listener user got each of their own changes twice. The relay now drops one whose sender is the connection's own nick, as it already does for PRIVMSG, NOTICE, JOIN, PART, NICK and QUIT. The direct echo stays: it carries the real hostmask and arrives in order. The README MODE section no longer describes a second copy. Model: opus-5-5 --- README.md | 5 +-- internal/ircserver/relay.go | 27 +++++++++++++ internal/ircserver/relay_test.go | 67 ++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b94af90..7a31347 100644 --- a/README.md +++ b/README.md @@ -1019,9 +1019,8 @@ On the IRC listener, `MODE #channel [values...]` takes several letters in one string, e.g. `MODE #general +mt-i` or `MODE #general +ov alice bob`. It accepts `i`, `m`, `n`, `s`, `t`, `o` and `v` (with a nick) and `H` (with bits from 1 to 40 when setting), but not `b`, `k` or `l`. A letter whose value is -missing is skipped. The changes made are echoed to the operator's connection and -also sent to every channel member, the operator included, as one `MODE` line, -e.g. `+o-v alice bob`. +missing is skipped. The changes made are sent to every channel member as one +`MODE` line, e.g. `+o-v alice bob`; the operator gets that line once. **Channel errors:** diff --git a/internal/ircserver/relay.go b/internal/ircserver/relay.go index c568133..bf88c2e 100644 --- a/internal/ircserver/relay.go +++ b/internal/ircserver/relay.go @@ -304,6 +304,15 @@ func (c *Conn) deliverTopicChange( msg *db.IRCMessage, text string, ) { + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + + // handleTopic already echoed our own topic change. + if msg.From == nick { + return + } + prefix := msg.From + "!" + msg.From + "@*" channel := msg.To @@ -315,6 +324,15 @@ func (c *Conn) deliverKickMsg( msg *db.IRCMessage, text string, ) { + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + + // handleKick already echoed our own kick. + if msg.From == nick { + return + } + prefix := msg.From + "!" + msg.From + "@*" channel := msg.To @@ -377,6 +395,15 @@ func (c *Conn) deliverMode( return } + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + + // applyChannelModes already echoed our own mode change. + if msg.From == nick { + return + } + prefix := msg.From + "!" + msg.From + "@*" params := slices.Concat( diff --git a/internal/ircserver/relay_test.go b/internal/ircserver/relay_test.go index 80d2ace..2b6a89e 100644 --- a/internal/ircserver/relay_test.go +++ b/internal/ircserver/relay_test.go @@ -292,3 +292,70 @@ func TestRelayNickDiffersOnlyInCase(t *testing.T) { } } } + +// 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) + } + } + }) + } +} -- 2.54.0 From ddfbdc1277be9bf3a5bd617ff87057033ff70395 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 21 Oct 2026 06:31:29 +0200 Subject: [PATCH 36/40] Keep the web client's saved channels and show who changed a mode (closes #176) The web client saved its channel tabs to localStorage whenever the tabs changed, including on page load, when only the Server tab exists. That wrote an empty list over the saved one before login read it, so a new session never rejoined anything. The save now waits until the user is logged in, and login reads the saved list before it marks the user logged in. A MODE message from the server fell through to the Server tab as its bare body. It now shows in the channel's tab as "alice sets mode +v bob on #chan". Model: opus-5-5 --- web/src/app.jsx | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/web/src/app.jsx b/web/src/app.jsx index 932ee72..c3ad50f 100644 --- a/web/src/app.jsx +++ b/web/src/app.jsx @@ -300,11 +300,14 @@ function App() { nickRef.current = nick; }, [nick]); - // Persist joined channels. + // Persist joined channels, once logged in: before then only the + // Server tab exists, and writing would erase the saved channels + // that onLogin rejoins. useEffect(() => { + if (!loggedIn) return; const channels = tabs.filter((t) => t.type === "channel").map((t) => t.name); localStorage.setItem("neoirc_channels", JSON.stringify(channels)); - }, [tabs]); + }, [loggedIn, tabs]); // Clear unread on tab switch. useEffect(() => { @@ -463,6 +466,15 @@ function App() { break; } + case "MODE": { + // The body is the change, with its value where it has one: + // ["+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. @@ -759,6 +771,11 @@ function App() { const onLogin = useCallback( 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); setLoggedIn(true); addSystemMessage("Server", `Connected as ${userNick}`); @@ -783,9 +800,6 @@ function App() { } // Fresh session — join any previously saved channels. - const saved = JSON.parse( - localStorage.getItem("neoirc_channels") || "[]", - ); for (const ch of saved) { try { await api("/messages", { -- 2.54.0 From 3cbd7554e2897d4a7c685495d9a57b8e984a43d5 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 21 Oct 2026 07:31:38 +0200 Subject: [PATCH 37/40] Relay INVITE to IRC listener users as an INVITE line (closes #147) An invite for an IRC listener user arrived as a server NOTICE that did not name the inviter, so no IRC client treated it as an invite. The relay now sends it as an INVITE line from the inviter, with the channel taken from the message body, and sends nothing when the body names no channel. The IRC listener now queues the INVITE with the channel as its body, as the HTTP API already did, so an invite from either transport has the same shape. The README's IRC listener section says how an invite arrives. Model: opus-5-5 --- README.md | 4 ++- internal/ircserver/commands.go | 7 +++-- internal/ircserver/integration_test.go | 36 ++++++++++++++++++++++++++ internal/ircserver/relay.go | 14 +++++++--- internal/ircserver/relay_test.go | 32 +++++++++++++++++++++++ 5 files changed, 85 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7a31347..600fd51 100644 --- a/README.md +++ b/README.md @@ -2719,7 +2719,9 @@ Each line of a multi-line `body` reaches an IRC listener user as its own is sent, so it cannot start a line of its own. A mode change made over the HTTP API reaches IRC listener members as an IRC `MODE` line with its value as a separate parameter, e.g. `MODE #general +k secretpass` or -`MODE #general +o bob`. +`MODE #general +o bob`. An `INVITE` from either transport reaches the invited +IRC listener user as an IRC `INVITE` line from the inviter, e.g. +`:alice!alice@* INVITE bob #general`. ### Docker Usage diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index 77d1eb3..359a2df 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -1261,10 +1261,9 @@ func (c *Conn) handleInvite( irc.RplInviting, targetNick, channel, ) - // Send INVITE notice to target via service fan-out. - body, _ := json.Marshal( //nolint:errchkjson - []string{"You have been invited to " + channel}, - ) + // Queue the INVITE to the target with the body the HTTP + // API uses. + body, _ := json.Marshal([]string{channel}) //nolint:errchkjson _, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast ctx, "INVITE", c.nick, targetNick, targetID, 0, diff --git a/internal/ircserver/integration_test.go b/internal/ircserver/integration_test.go index 2dde41f..db4b4f2 100644 --- a/internal/ircserver/integration_test.go +++ b/internal/ircserver/integration_test.go @@ -1373,6 +1373,42 @@ func TestIntegrationInviteUserOnChannel(t *testing.T) { ) } +// 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) { diff --git a/internal/ircserver/relay.go b/internal/ircserver/relay.go index bf88c2e..96125a0 100644 --- a/internal/ircserver/relay.go +++ b/internal/ircserver/relay.go @@ -358,16 +358,24 @@ func (c *Conn) deliverKickMsg( } } -// deliverInviteMsg sends an INVITE notification. +// deliverInviteMsg sends an INVITE line from the inviter +// for the channel named in text, or nothing when text names +// no channel. func (c *Conn) deliverInviteMsg( - _ *db.IRCMessage, + msg *db.IRCMessage, text string, ) { + if text == "" { + return + } + c.mu.Lock() nick := c.nick c.mu.Unlock() - c.sendFromServer("NOTICE", nick, text) + prefix := msg.From + "!" + msg.From + "@*" + + c.send(FormatMessage(prefix, "INVITE", nick, text)) } // deliverWallops sends a WALLOPS notification. diff --git a/internal/ircserver/relay_test.go b/internal/ircserver/relay_test.go index 2b6a89e..c0d4b22 100644 --- a/internal/ircserver/relay_test.go +++ b/internal/ircserver/relay_test.go @@ -211,6 +211,38 @@ func TestRelayModeParameters(t *testing.T) { } } +// 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 -- 2.54.0 From d539b4c558a61194e995bec86ca56aba86cb2051 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 21 Oct 2026 13:06:01 +0200 Subject: [PATCH 38/40] Make TestIdleCleanupKeepsActiveUser independent of timing (closes #181) The test slept 500 ms, had one user send PING, and then asked for sessions last seen before the current time minus 500 ms. On a loaded host the PONG could arrive more than 500 ms later, so the active user counted as idle and the build went red. The code was right: activity is recorded before the reply and the stored time keeps nanoseconds. The test now reads the clock once after both users register and before the PING, and uses that as the cutoff, so the result follows from the order of events. Judgement call: the test relies on the wall clock not stepping backwards between the cutoff and the PING. Model: opus-5-5 --- internal/ircserver/server_test.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index 3b7eabf..a530b36 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -645,8 +645,6 @@ func TestNewPingTimes(t *testing.T) { func TestIdleCleanupKeepsActiveUser(t *testing.T) { t.Parallel() - const idleTimeout = 500 * time.Millisecond - env := newTestEnv(t) active := env.dial(t) @@ -655,13 +653,17 @@ func TestIdleCleanupKeepsActiveUser(t *testing.T) { idle := env.dial(t) idle.register("idle") - time.Sleep(idleTimeout) + // 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(), time.Now().Add(-idleTimeout), + t.Context(), cutoff, ) if err != nil { t.Fatalf("get stale sessions: %v", err) -- 2.54.0 From 129d87d578cb07e3cf01a97147e77a5e3859bbf6 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 21 Oct 2026 15:23:13 +0200 Subject: [PATCH 39/40] Show an IRC listener user what their session sends from other clients (closes #179) The IRC listener's relay dropped every line sent under the connection's own nick, so a user on the web client and an IRC client at once never saw on IRC what they did from the web client. The relay now drops nothing by nick. Instead the fan-out skips only the sending client's own queue: the IRC listener passes its own client, whose handler already wrote the echo, and the HTTP API skips none. Channel messages and PARTs now reach the sender's other clients, so an HTTP client gets its own PART event; neoirc-cli ignores it, as it already shows its own part. README.md names the commands covered. Judgement call: a PRIVMSG or NOTICE sent on an IRC connection is not echoed back to it, as in IRC. Model: opus-5-5 --- README.md | 20 +-- internal/cli/app.go | 10 +- internal/cli/app_internal_test.go | 65 +++++++++ internal/db/queries.go | 19 +-- internal/db/queries_test.go | 2 +- internal/handlers/api.go | 33 ++--- internal/handlers/api_test.go | 2 +- internal/ircserver/commands.go | 20 +-- internal/ircserver/export_test.go | 5 + internal/ircserver/relay.go | 70 ---------- internal/ircserver/relay_test.go | 212 +++++++++++++++++++++++++++++- internal/service/service.go | 132 +++++++++---------- internal/service/service_test.go | 112 ++++++++-------- 13 files changed, 455 insertions(+), 247 deletions(-) diff --git a/README.md b/README.md index 600fd51..7acab63 100644 --- a/README.md +++ b/README.md @@ -268,10 +268,15 @@ rules). Case-sensitive comparison is unambiguous. A single user session can have multiple clients (phone, laptop, terminal). - Each client gets a **separate server-to-client (S2C) message queue**. -- The server fans out messages and events from other users, and the user's own - DMs and the channel messages it sends over the HTTP API, to every client queue - for that user session. Replies to a command (numerics) go only to the client - that sent it. +- The server fans out messages and events from other users to every client queue + for that user session. +- When the user sends a `PRIVMSG`, `NOTICE`, `JOIN`, `PART`, `NICK`, `TOPIC`, + `MODE` or `KICK` from one of its clients, the resulting event goes to every + client queue of the user's own session, as well as to the other users it is + meant for. The one exception is an IRC listener connection that sent the + command, which gets nothing queued for it: it has already sent its user the + echo, or, for `PRIVMSG` and `NOTICE`, IRC sends none. +- Replies to a command (numerics) go only to the client that sent it. - `GET /api/v1/messages` delivers from the calling client's specific queue, identified by the auth cookie. - Client queues have **independent expiry/pruning** — one client going offline @@ -744,7 +749,7 @@ Leave a channel. {"command": "PART", "to": "#general", "body": ["goodbye"]} ``` -**S2C (broadcast to the other channel members):** +**S2C (broadcast to all channel members, including the departing user):** ```json { @@ -760,9 +765,8 @@ Leave a channel. **Behavior:** -- The PART event goes to the other channel members, not to the departing user; - the HTTP response confirms the part. (The IRC listener echoes the PART to its - own connection.) +- The PART event goes to every channel member, the departing user's own clients + included. (An IRC listener connection that sends a PART echoes it itself.) - If the channel is empty after the user leaves, the channel is **deleted** (ephemeral channels). - If the user is not in the channel, the server returns an error. diff --git a/internal/cli/app.go b/internal/cli/app.go index 85df5c8..4bacf80 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -757,7 +757,7 @@ func (a *App) handleServerMessage(msg *api.Message) { case irc.CmdJoin: a.handleJoinEvent(msg, timestamp) case irc.CmdPart: - a.handlePartEvent(msg, timestamp) + a.handlePartEvent(msg, timestamp, myNick) case irc.CmdQuit: a.handleQuitEvent(msg, timestamp) case irc.CmdNick: @@ -813,10 +813,14 @@ 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( - msg *api.Message, timestamp string, + msg *api.Message, timestamp, myNick string, ) { - if msg.To == "" { + if msg.To == "" || msg.From == myNick { return } diff --git a/internal/cli/app_internal_test.go b/internal/cli/app_internal_test.go index 985a493..9aa41d7 100644 --- a/internal/cli/app_internal_test.go +++ b/internal/cli/app_internal_test.go @@ -3,7 +3,9 @@ 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) { @@ -49,3 +51,66 @@ func TestMemberPrefix(t *testing.T) { }) } } + +// 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) + } + }) +} diff --git a/internal/db/queries.go b/internal/db/queries.go index 8b216a1..11aa6f3 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -1208,11 +1208,12 @@ func insertMessageInTx( return dbID, msgUUID, nil } -// EnqueueToSession adds a message to all clients of a -// session's queues. +// EnqueueToSession adds a message to the queues of all +// clients of a session except client skipClientID, or of all +// of them when skipClientID is 0. func (database *Database) EnqueueToSession( ctx context.Context, - sessionID, messageID int64, + sessionID, messageID, skipClientID int64, ) error { transaction, err := database.conn.BeginTx(ctx, nil) if err != nil { @@ -1221,7 +1222,9 @@ func (database *Database) EnqueueToSession( defer func() { _ = transaction.Rollback() }() - err = enqueueToSessionInTx(ctx, transaction, sessionID, messageID) + err = enqueueToSessionInTx( + ctx, transaction, sessionID, messageID, skipClientID, + ) if err != nil { return err } @@ -1239,15 +1242,15 @@ func (database *Database) EnqueueToSession( func enqueueToSessionInTx( ctx context.Context, transaction *sql.Tx, - sessionID, messageID int64, + sessionID, messageID, skipClientID int64, ) error { _, err := transaction.ExecContext(ctx, `INSERT OR IGNORE INTO client_queues (client_id, message_id, created_at) SELECT c.id, ?, ? FROM clients c - WHERE c.session_id = ?`, - messageID, time.Now(), sessionID) + WHERE c.session_id = ? AND c.id != ?`, + messageID, time.Now(), sessionID, skipClientID) if err != nil { return fmt.Errorf( "enqueue to session: %w", err, @@ -1673,7 +1676,7 @@ func quitSessionInTx( for _, peerID := range peerIDs { err = enqueueToSessionInTx( - ctx, transaction, peerID, messageID, + ctx, transaction, peerID, messageID, 0, ) if err != nil { return nil, err diff --git a/internal/db/queries_test.go b/internal/db/queries_test.go index 29f9928..881fec6 100644 --- a/internal/db/queries_test.go +++ b/internal/db/queries_test.go @@ -675,7 +675,7 @@ func TestPollMessages(t *testing.T) { t.Fatal(err) } - err = database.EnqueueToSession(ctx, sid, dbID) + err = database.EnqueueToSession(ctx, sid, dbID, 0) if err != nil { t.Fatal(err) } diff --git a/internal/handlers/api.go b/internal/handlers/api.go index f6f6873..792354d 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -1257,9 +1257,11 @@ func (hdlr *Handlers) handleChannelMsg( ) { ctx := request.Context() - // Delegate validation + fan-out to service layer. - dbID, uuid, err := hdlr.svc.SendChannelMessage( - ctx, sessionID, nick, + // Delegate validation + fan-out to service layer. The + // fan-out includes the sender's own session, so all + // their clients see the message in long-poll responses. + uuid, err := hdlr.svc.SendChannelMessage( + ctx, sessionID, 0, nick, command, target, body, meta, ) if hdlr.handleServiceError( @@ -1269,13 +1271,6 @@ func (hdlr *Handlers) handleChannelMsg( 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, map[string]string{"id": uuid, statusKey: "sent"}, http.StatusOK) @@ -1290,7 +1285,7 @@ func (hdlr *Handlers) handleDirectMsg( meta json.RawMessage, ) { result, err := hdlr.svc.SendDirectMessage( - request.Context(), sessionID, nick, + request.Context(), sessionID, 0, nick, command, target, body, meta, ) if hdlr.handleServiceError( @@ -1361,7 +1356,7 @@ func (hdlr *Handlers) executeJoin( nick, channel, suppliedKey string, ) { result, err := hdlr.svc.JoinChannel( - request.Context(), sessionID, nick, + request.Context(), sessionID, 0, nick, channel, suppliedKey, ) if hdlr.handleServiceError( @@ -1545,7 +1540,7 @@ func (hdlr *Handlers) handlePart( } err := hdlr.svc.PartChannel( - request.Context(), sessionID, + request.Context(), sessionID, 0, nick, channel, reason, ) if hdlr.handleServiceError( @@ -1616,7 +1611,7 @@ func (hdlr *Handlers) executeNickChange( nick, newNick string, ) { err := hdlr.svc.ChangeNick( - request.Context(), sessionID, nick, newNick, + request.Context(), sessionID, 0, nick, newNick, ) if hdlr.handleServiceError( writer, request, @@ -1671,7 +1666,7 @@ func (hdlr *Handlers) handleTopic( topic := strings.Join(lines, " ") err := hdlr.svc.SetTopic( - request.Context(), sessionID, + request.Context(), sessionID, 0, nick, channel, topic, ) if hdlr.handleServiceError( @@ -2157,7 +2152,7 @@ func (hdlr *Handlers) applyUserMode( modeText := modeArgs[0] + " " + targetNick hdlr.svc.BroadcastMode( - request.Context(), nick, channel, chID, + request.Context(), 0, nick, channel, chID, modeText, ) @@ -2203,7 +2198,7 @@ func (hdlr *Handlers) setChannelFlag( } hdlr.svc.BroadcastMode( - request.Context(), nick, channel, chID, + request.Context(), 0, nick, channel, chID, modeStr, ) @@ -3650,7 +3645,7 @@ func (hdlr *Handlers) handleKick( } err := hdlr.svc.KickUser( - request.Context(), sessionID, nick, + request.Context(), sessionID, 0, nick, channel, targetNick, reason, ) if hdlr.handleServiceError( @@ -3723,7 +3718,7 @@ func (hdlr *Handlers) fanOut( for _, sid := range sessionIDs { enqErr := hdlr.params.Database.EnqueueToSession( - request.Context(), sid, dbID, + request.Context(), sid, dbID, 0, ) if enqErr != nil { hdlr.log.Error("enqueue failed", diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 4d5cb9c..6ca6421 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -2905,7 +2905,7 @@ func (tserver *testServer) queueMessage( return fmt.Errorf("insert message: %w", err) } - err = tserver.database.EnqueueToSession(ctx, sessionID, msgID) + err = tserver.database.EnqueueToSession(ctx, sessionID, msgID, 0) if err != nil { return fmt.Errorf("enqueue: %w", err) } diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index 359a2df..3972026 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -84,7 +84,7 @@ func (c *Conn) handleNick( oldNick := c.nick err := c.svc.ChangeNick( - ctx, c.sessionID, oldNick, newNick, + ctx, c.sessionID, c.clientID, oldNick, newNick, ) if err != nil { c.sendIRCError(err) @@ -128,8 +128,8 @@ func (c *Conn) handlePrivmsg( body, _ := json.Marshal([]string{text}) //nolint:errchkjson if strings.HasPrefix(target, "#") { - _, _, err := c.svc.SendChannelMessage( - ctx, c.sessionID, c.nick, + _, err := c.svc.SendChannelMessage( + ctx, c.sessionID, c.clientID, c.nick, msg.Command, target, body, nil, ) if err != nil { @@ -137,7 +137,7 @@ func (c *Conn) handlePrivmsg( } } else { result, err := c.svc.SendDirectMessage( - ctx, c.sessionID, c.nick, + ctx, c.sessionID, c.clientID, c.nick, msg.Command, target, body, nil, ) if err != nil { @@ -199,7 +199,7 @@ func (c *Conn) joinChannel( channel, key string, ) { result, err := c.svc.JoinChannel( - ctx, c.sessionID, c.nick, channel, key, + ctx, c.sessionID, c.clientID, c.nick, channel, key, ) if err != nil { c.sendIRCError(err) @@ -337,7 +337,7 @@ func (c *Conn) partChannel( channel, reason string, ) { err := c.svc.PartChannel( - ctx, c.sessionID, c.nick, channel, reason, + ctx, c.sessionID, c.clientID, c.nick, channel, reason, ) if err != nil { c.sendIRCError(err) @@ -401,7 +401,7 @@ func (c *Conn) handleTopic( newTopic := msg.Params[1] err := c.svc.SetTopic( - ctx, c.sessionID, c.nick, channel, newTopic, + ctx, c.sessionID, c.clientID, c.nick, channel, newTopic, ) if err != nil { c.sendIRCError(err) @@ -708,7 +708,7 @@ func (c *Conn) applyChannelModes( c.send(FormatMessage(c.hostmask(), "MODE", params...)) c.svc.BroadcastMode( - ctx, c.nick, channel, chID, modeReply, + ctx, c.clientID, c.nick, channel, chID, modeReply, ) } } @@ -1185,7 +1185,7 @@ func (c *Conn) handleKick( } err := c.svc.KickUser( - ctx, c.sessionID, c.nick, + ctx, c.sessionID, c.clientID, c.nick, channel, targetNick, reason, ) if err != nil { @@ -1267,7 +1267,7 @@ func (c *Conn) handleInvite( _, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast ctx, "INVITE", c.nick, targetNick, targetID, 0, - nil, body, nil, []int64{targetID}, + nil, body, nil, []int64{targetID}, 0, ) } diff --git a/internal/ircserver/export_test.go b/internal/ircserver/export_test.go index 69b8b61..5357b7e 100644 --- a/internal/ircserver/export_test.go +++ b/internal/ircserver/export_test.go @@ -71,6 +71,11 @@ func (s *Server) Listener() net.Listener { return s.listener } +// Service returns the service the server's connections use. +func (s *Server) Service() *service.Service { + return s.svc +} + // 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. diff --git a/internal/ircserver/relay.go b/internal/ircserver/relay.go index 96125a0..581fb7f 100644 --- a/internal/ircserver/relay.go +++ b/internal/ircserver/relay.go @@ -198,15 +198,6 @@ func (c *Conn) deliverTextMessage( from := msg.From target := msg.To - c.mu.Lock() - nick := c.nick - c.mu.Unlock() - - // Don't echo our own messages back. - if from == nick { - return - } - prefix := from if !strings.Contains(prefix, "!") { prefix = from + "!" + from + "@*" @@ -219,16 +210,6 @@ func (c *Conn) deliverTextMessage( // deliverJoin sends a JOIN notification. 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 msg.From == nick { - return - } - prefix := msg.From + "!" + msg.From + "@*" channel := msg.To @@ -237,14 +218,6 @@ func (c *Conn) deliverJoin(msg *db.IRCMessage) { // deliverPart sends a PART notification. func (c *Conn) deliverPart(msg *db.IRCMessage, text string) { - c.mu.Lock() - nick := c.nick - c.mu.Unlock() - - if msg.From == nick { - return - } - prefix := msg.From + "!" + msg.From + "@*" channel := msg.To @@ -262,14 +235,6 @@ func (c *Conn) deliverNickChange( msg *db.IRCMessage, newNick string, ) { - c.mu.Lock() - nick := c.nick - c.mu.Unlock() - - if msg.From == nick { - return - } - prefix := msg.From + "!" + msg.From + "@*" c.send(FormatMessage(prefix, "NICK", newNick)) @@ -280,14 +245,6 @@ func (c *Conn) deliverQuitMsg( msg *db.IRCMessage, text string, ) { - c.mu.Lock() - nick := c.nick - c.mu.Unlock() - - if msg.From == nick { - return - } - prefix := msg.From + "!" + msg.From + "@*" if text != "" { @@ -304,15 +261,6 @@ func (c *Conn) deliverTopicChange( msg *db.IRCMessage, text string, ) { - c.mu.Lock() - nick := c.nick - c.mu.Unlock() - - // handleTopic already echoed our own topic change. - if msg.From == nick { - return - } - prefix := msg.From + "!" + msg.From + "@*" channel := msg.To @@ -324,15 +272,6 @@ func (c *Conn) deliverKickMsg( msg *db.IRCMessage, text string, ) { - c.mu.Lock() - nick := c.nick - c.mu.Unlock() - - // handleKick already echoed our own kick. - if msg.From == nick { - return - } - prefix := msg.From + "!" + msg.From + "@*" channel := msg.To @@ -403,15 +342,6 @@ func (c *Conn) deliverMode( return } - c.mu.Lock() - nick := c.nick - c.mu.Unlock() - - // applyChannelModes already echoed our own mode change. - if msg.From == nick { - return - } - prefix := msg.From + "!" + msg.From + "@*" params := slices.Concat( diff --git a/internal/ircserver/relay_test.go b/internal/ircserver/relay_test.go index c0d4b22..71affec 100644 --- a/internal/ircserver/relay_test.go +++ b/internal/ircserver/relay_test.go @@ -1,6 +1,7 @@ package ircserver_test import ( + "context" "encoding/json" "slices" "strings" @@ -8,6 +9,7 @@ import ( "time" "sneak.berlin/go/neoirc/internal/ircserver" + "sneak.berlin/go/neoirc/internal/service" ) // queueMessage queues a message for the user with nick @@ -39,7 +41,7 @@ func (env *testEnv) queueMessage( t.Fatalf("get session of %s: %v", recipient, err) } - err = env.database.EnqueueToSession(ctx, sessionID, msgID) + err = env.database.EnqueueToSession(ctx, sessionID, msgID, 0) if err != nil { t.Fatalf("enqueue: %v", err) } @@ -391,3 +393,211 @@ func TestRelayOwnChangeOnce(t *testing.T) { }) } } + +// 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 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 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() + + const password = "alicepass" + + ctx := t.Context() + env := newTestEnv(t) + + 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( + ctx, "alice", password, "127.0.0.1", "localhost", + ) + if err != nil { + t.Fatalf("log in as alice: %v", err) + } + + 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 +} diff --git a/internal/service/service.go b/internal/service/service.go index 716549f..f16340d 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -312,7 +312,7 @@ func (s *Service) SendWallops( _, _, err = s.FanOut( ctx, irc.CmdWallops, nick, "*", 0, 0, - nil, body, nil, recipients, + nil, body, nil, recipients, 0, ) if err != nil { return fmt.Errorf("wallops: %w", err) @@ -347,12 +347,19 @@ type DirectMsgResult struct { // session IDs, notifying each via the broker. toSessionID // and toChannelID are the recipient InsertMessage records // for history, 0 when there is none. +// +// skipClientID is the client whose queue the message is +// not added to: the IRC listener connection that sent the +// command, which has already shown it to its user. The HTTP +// API passes 0, as its clients see their own commands +// through their queues. func (s *Service) FanOut( ctx context.Context, command, from, to string, toSessionID, toChannelID int64, params, body, meta json.RawMessage, sessionIDs []int64, + skipClientID int64, ) (int64, string, error) { dbID, msgUUID, err := s.db.InsertMessage( ctx, command, from, to, @@ -363,58 +370,40 @@ func (s *Service) FanOut( } for _, sid := range sessionIDs { - _ = s.db.EnqueueToSession(ctx, sid, dbID) + _ = s.db.EnqueueToSession(ctx, sid, dbID, skipClientID) s.broker.Notify(sid) } return dbID, msgUUID, nil } -// excludeSession returns a copy of ids without the given -// session. -func excludeSession( - ids []int64, - exclude int64, -) []int64 { - out := make([]int64, 0, len(ids)) - - for _, id := range ids { - if id != exclude { - out = append(out, id) - } - } - - return out -} - // SendChannelMessage checks membership, bans and // moderation, then the hashcash stamp of a PRIVMSG to a +H -// channel, then fans out a message to all channel members -// except the sender. The stamp is checked after the other -// checks so that a message they refuse does not spend it. -// Returns the database row ID, message UUID, and any error. -// The dbID lets callers enqueue the same message to the -// sender when echo is needed (HTTP transport). +// channel, then fans out a message to all channel members, +// the sender's session included, skipping client +// skipClientID as FanOut does. The stamp is checked after +// the other checks so that a message they refuse does not +// spend it. Returns the message UUID and any error. func (s *Service) SendChannelMessage( ctx context.Context, - sessionID int64, + sessionID, skipClientID int64, nick, command, channel string, body, meta json.RawMessage, -) (int64, string, error) { +) (string, error) { noSuchChannel := &IRCError{ irc.ErrNoSuchChannel, []string{channel}, msgNoSuchChannel, } chID, err := s.db.GetChannelByName(ctx, channel) if err != nil { - return 0, "", noSuchChannel + return "", noSuchChannel } isMember, _ := s.db.IsChannelMember( ctx, chID, sessionID, ) if !isMember { - return 0, "", &IRCError{ + return "", &IRCError{ irc.ErrCannotSendToChan, []string{channel}, "Cannot send to channel", @@ -426,7 +415,7 @@ func (s *Service) SendChannelMessage( ctx, chID, sessionID, ) if banErr == nil && isBanned { - return 0, "", &IRCError{ + return "", &IRCError{ irc.ErrCannotSendToChan, []string{channel}, "Cannot send to channel (+b)", @@ -443,7 +432,7 @@ func (s *Service) SendChannelMessage( ) if !isOp && !isVoiced { - return 0, "", &IRCError{ + return "", &IRCError{ irc.ErrCannotSendToChan, []string{channel}, "Cannot send to channel (+m)", @@ -456,35 +445,35 @@ func (s *Service) SendChannelMessage( ctx, chID, channel, body, meta, ) if err != nil { - return 0, "", err + return "", err } } memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID) - recipients := excludeSession(memberIDs, sessionID) - dbID, uuid, fanErr := s.FanOut( + _, uuid, fanErr := s.FanOut( ctx, command, nick, channel, 0, chID, - nil, body, meta, recipients, + nil, body, meta, memberIDs, skipClientID, ) // The channel was deleted after the lookup above. if db.IsForeignKeyConstraintError(fanErr) { - return 0, "", noSuchChannel + return "", noSuchChannel } if fanErr != nil { - return 0, "", fanErr + return "", fanErr } - return dbID, uuid, nil + return uuid, nil } // SendDirectMessage validates the target and sends a -// direct message, returning the message UUID and any away -// message set on the target. +// direct message to the target and the sender's session, +// skipping client skipClientID as FanOut does, and returns +// the message UUID and any away message set on the target. func (s *Service) SendDirectMessage( ctx context.Context, - sessionID int64, + sessionID, skipClientID int64, nick, command, target string, body, meta json.RawMessage, ) (*DirectMsgResult, error) { @@ -506,7 +495,7 @@ func (s *Service) SendDirectMessage( _, uuid, fanErr := s.FanOut( ctx, command, nick, target, targetSID, 0, - nil, body, meta, recipients, + nil, body, meta, recipients, skipClientID, ) // The target's session was deleted after the lookup above. if db.IsForeignKeyConstraintError(fanErr) { @@ -522,11 +511,12 @@ func (s *Service) SendDirectMessage( // JoinChannel creates or joins a channel, making the // first joiner the operator. Fans out the JOIN to all -// channel members. A channel name that validChannelRe -// does not match gets ERR_NOSUCHCHANNEL. +// channel members, skipping client skipClientID as FanOut +// does. A channel name that validChannelRe does not match +// gets ERR_NOSUCHCHANNEL. func (s *Service) JoinChannel( ctx context.Context, - sessionID int64, + sessionID, skipClientID int64, nick, channel, suppliedKey string, ) (*JoinResult, error) { if !validChannelRe.MatchString(channel) { @@ -549,7 +539,7 @@ func (s *Service) JoinChannel( _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast ctx, irc.CmdJoin, nick, channel, 0, chID, - nil, body, nil, memberIDs, + nil, body, nil, memberIDs, skipClientID, ) return &JoinResult{ @@ -558,12 +548,12 @@ func (s *Service) JoinChannel( }, nil } -// PartChannel validates membership, broadcasts PART to -// remaining members, removes the user, and cleans up empty -// channels. +// PartChannel validates membership, broadcasts PART to all +// channel members, skipping client skipClientID as FanOut +// does, removes the user, and cleans up empty channels. func (s *Service) PartChannel( ctx context.Context, - sessionID int64, + sessionID, skipClientID int64, nick, channel, reason string, ) error { chID, err := s.db.GetChannelByName(ctx, channel) @@ -587,12 +577,11 @@ func (s *Service) PartChannel( } memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID) - recipients := excludeSession(memberIDs, sessionID) body, _ := json.Marshal([]string{reason}) //nolint:errchkjson _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast ctx, irc.CmdPart, nick, channel, 0, chID, - nil, body, nil, recipients, + nil, body, nil, memberIDs, skipClientID, ) s.db.PartChannelAndDeleteIfEmpty(ctx, chID, sessionID) //nolint:errcheck,gosec @@ -601,10 +590,11 @@ func (s *Service) PartChannel( } // SetTopic validates membership and topic-lock, sets the -// topic, and broadcasts the change. +// topic, and broadcasts the change to all channel members, +// skipping client skipClientID as FanOut does. func (s *Service) SetTopic( ctx context.Context, - sessionID int64, + sessionID, skipClientID int64, nick, channel, topic string, ) error { chID, err := s.db.GetChannelByName(ctx, channel) @@ -653,18 +643,19 @@ func (s *Service) SetTopic( _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast ctx, irc.CmdTopic, nick, channel, 0, chID, - nil, body, nil, memberIDs, + nil, body, nil, memberIDs, skipClientID, ) return nil } // KickUser validates operator status and target -// membership, broadcasts the KICK, removes the target, -// and cleans up empty channels. +// membership, broadcasts the KICK to all channel members, +// skipping client skipClientID as FanOut does, removes the +// target, and cleans up empty channels. func (s *Service) KickUser( ctx context.Context, - sessionID int64, + sessionID, skipClientID int64, nick, channel, targetNick, reason string, ) error { chID, err := s.db.GetChannelByName(ctx, channel) @@ -717,7 +708,7 @@ func (s *Service) KickUser( _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast ctx, irc.CmdKick, nick, channel, 0, chID, - params, body, nil, memberIDs, + params, body, nil, memberIDs, skipClientID, ) s.db.PartChannelAndDeleteIfEmpty(ctx, chID, targetSID) //nolint:errcheck,gosec @@ -798,10 +789,11 @@ func (s *Service) InviteUser( } // ChangeNick changes a user's nickname and broadcasts the -// change to all users sharing channels. +// change to the user's own session and all users sharing +// channels, skipping client skipClientID as FanOut does. func (s *Service) ChangeNick( ctx context.Context, - sessionID int64, + sessionID, skipClientID int64, oldNick, newNick string, ) error { err := s.db.ChangeNick(ctx, sessionID, newNick) @@ -822,7 +814,9 @@ func (s *Service) ChangeNick( } } - s.broadcastNickChange(ctx, sessionID, oldNick, newNick) + s.broadcastNickChange( + ctx, sessionID, skipClientID, oldNick, newNick, + ) return nil } @@ -1042,9 +1036,10 @@ func (s *Service) SetChannelFlag( } // BroadcastMode fans out a MODE change to all channel -// members. +// members, skipping client skipClientID as FanOut does. func (s *Service) BroadcastMode( ctx context.Context, + skipClientID int64, nick, channel string, chID int64, modeText string, @@ -1054,7 +1049,7 @@ func (s *Service) BroadcastMode( _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast ctx, irc.CmdMode, nick, channel, 0, chID, - nil, body, nil, memberIDs, + nil, body, nil, memberIDs, skipClientID, ) } @@ -1306,11 +1301,12 @@ func (s *Service) checkChannelHashcash( return nil } -// broadcastNickChange notifies channel peers of a nick +// broadcastNickChange notifies the user's own session, +// except client skipClientID, and channel peers of a nick // change. func (s *Service) broadcastNickChange( ctx context.Context, - sessionID int64, + sessionID, skipClientID int64, oldNick, newNick string, ) { channels, err := s.db.GetSessionChannels( @@ -1332,7 +1328,7 @@ func (s *Service) broadcastNickChange( } // Notify the user themselves (for multi-client sync). - _ = s.db.EnqueueToSession(ctx, sessionID, dbID) + _ = s.db.EnqueueToSession(ctx, sessionID, dbID, skipClientID) s.broker.Notify(sessionID) notified[sessionID] = true @@ -1351,7 +1347,7 @@ func (s *Service) broadcastNickChange( notified[mid] = true - _ = s.db.EnqueueToSession(ctx, mid, dbID) + _ = s.db.EnqueueToSession(ctx, mid, dbID, 0) s.broker.Notify(mid) } } diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 79ba6bb..3ad3279 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -168,7 +168,7 @@ func TestFanOut(t *testing.T) { dbID, uuid, err := env.svc.FanOut( ctx, irc.CmdPrivmsg, "alice", "#test", 0, 0, nil, body, nil, - []int64{sid1, sid2}, + []int64{sid1, sid2}, 0, ) if err != nil { t.Fatalf("FanOut: %v", err) @@ -190,7 +190,7 @@ func TestJoinChannel(t *testing.T) { sid := createSession(ctx, t, env.db, "alice") result, err := env.svc.JoinChannel( - ctx, sid, "alice", "#general", "", + ctx, sid, 0, "alice", "#general", "", ) if err != nil { t.Fatalf("JoinChannel: %v", err) @@ -208,7 +208,7 @@ func TestJoinChannel(t *testing.T) { sid2 := createSession(ctx, t, env.db, "bob") result2, err := env.svc.JoinChannel( - ctx, sid2, "bob", "#general", "", + ctx, sid2, 0, "bob", "#general", "", ) if err != nil { t.Fatalf("JoinChannel bob: %v", err) @@ -230,14 +230,14 @@ func TestPartChannel(t *testing.T) { sid := createSession(ctx, t, env.db, "alice") _, err := env.svc.JoinChannel( - ctx, sid, "alice", "#general", "", + ctx, sid, 0, "alice", "#general", "", ) if err != nil { t.Fatalf("JoinChannel: %v", err) } err = env.svc.PartChannel( - ctx, sid, "alice", "#general", "bye", + ctx, sid, 0, "alice", "#general", "bye", ) if err != nil { t.Fatalf("PartChannel: %v", err) @@ -245,7 +245,7 @@ func TestPartChannel(t *testing.T) { // Parting a non-existent channel returns error. err = env.svc.PartChannel( - ctx, sid, "alice", "#nonexistent", "", + ctx, sid, 0, "alice", "#nonexistent", "", ) if err == nil { t.Error("expected error for non-existent channel") @@ -309,7 +309,7 @@ func joinConcurrently( joins[i] = func() error { result, err := env.svc.JoinChannel( - ctx, sessionID, nick, channel, "", + ctx, sessionID, 0, nick, channel, "", ) results[i] = result @@ -335,7 +335,7 @@ func TestConcurrentJoinsRespectUserLimit(t *testing.T) { opSID := createSession(ctx, t, env.db, opNick) created, err := env.svc.JoinChannel( - ctx, opSID, opNick, channel, "", + ctx, opSID, 0, opNick, channel, "", ) if err != nil { t.Fatalf("join %s: %v", opNick, err) @@ -448,7 +448,7 @@ func TestJoinConcurrentWithLastPart(t *testing.T) { joinerSID := createSession(ctx, t, env.db, joinerNick) _, err := env.svc.JoinChannel( - ctx, leaverSID, leaverNick, channel, "", + ctx, leaverSID, 0, leaverNick, channel, "", ) if err != nil { t.Fatalf("join %s: %v", leaverNick, err) @@ -457,12 +457,12 @@ func TestJoinConcurrentWithLastPart(t *testing.T) { errs := runConcurrently( func() error { return env.svc.PartChannel( - ctx, leaverSID, leaverNick, channel, "", + ctx, leaverSID, 0, leaverNick, channel, "", ) }, func() error { _, joinErr := env.svc.JoinChannel( - ctx, joinerSID, joinerNick, channel, "", + ctx, joinerSID, 0, joinerNick, channel, "", ) return joinErr @@ -513,7 +513,7 @@ func TestJoinConcurrentWithLastQuit(t *testing.T) { joinerSID := createSession(ctx, t, env.db, joinerNick) created, err := env.svc.JoinChannel( - ctx, leaverSID, leaverNick, channel, "", + ctx, leaverSID, 0, leaverNick, channel, "", ) if err != nil { t.Fatalf("join %s: %v", leaverNick, err) @@ -545,7 +545,7 @@ func TestJoinConcurrentWithLastQuit(t *testing.T) { ) joined, joinErr = env.svc.JoinChannel( - ctx, joinerSID, joinerNick, channel, "", + ctx, joinerSID, 0, joinerNick, channel, "", ) if !errors.As(joinErr, &ircErr) || ircErr.Code != irc.ErrBadChannelKey { @@ -592,7 +592,7 @@ func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) { quitterSID := createSession(ctx, t, env.db, nick) for _, channel := range []string{crowd, shared} { - _, err := env.svc.JoinChannel(ctx, quitterSID, nick, channel, "") + _, err := env.svc.JoinChannel(ctx, quitterSID, 0, nick, channel, "") if err != nil { t.Fatalf("join %s: %v", channel, err) } @@ -602,7 +602,7 @@ func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) { memberNick := fmt.Sprintf("crowd%dx%d", trial, member) memberSID := createSession(ctx, t, env.db, memberNick) - _, err := env.svc.JoinChannel(ctx, memberSID, memberNick, crowd, "") + _, err := env.svc.JoinChannel(ctx, memberSID, 0, memberNick, crowd, "") if err != nil { t.Fatalf("join %s: %v", memberNick, err) } @@ -617,7 +617,7 @@ func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) { t.Fatalf("create session %s: %v", watcherNick, err) } - _, err = env.svc.JoinChannel(ctx, watcherSID, watcherNick, shared, "") + _, err = env.svc.JoinChannel(ctx, watcherSID, 0, watcherNick, shared, "") if err != nil { t.Fatalf("join %s: %v", watcherNick, err) } @@ -678,7 +678,7 @@ func takeNickAndJoin( return err } - _, err = env.svc.JoinChannel(ctx, sessionID, nick, channel, "") + _, err = env.svc.JoinChannel(ctx, sessionID, 0, nick, channel, "") return err } @@ -710,7 +710,7 @@ func TestJoinUsesUpInvite(t *testing.T) { opSID := createSession(ctx, t, env.db, "op") guestSID := createSession(ctx, t, env.db, "guest") - created, err := env.svc.JoinChannel(ctx, opSID, "op", channel, "") + created, err := env.svc.JoinChannel(ctx, opSID, 0, "op", channel, "") if err != nil { t.Fatalf("join op: %v", err) } @@ -725,17 +725,17 @@ func TestJoinUsesUpInvite(t *testing.T) { t.Fatalf("invite guest: %v", err) } - _, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "") + _, err = env.svc.JoinChannel(ctx, guestSID, 0, "guest", channel, "") if err != nil { t.Fatalf("invited join: %v", err) } - err = env.svc.PartChannel(ctx, guestSID, "guest", channel, "") + err = env.svc.PartChannel(ctx, guestSID, 0, "guest", channel, "") if err != nil { t.Fatalf("part guest: %v", err) } - _, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "") + _, err = env.svc.JoinChannel(ctx, guestSID, 0, "guest", channel, "") var ircErr *service.IRCError if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrInviteOnlyChan { @@ -754,7 +754,7 @@ func TestJoinRefusedByHostmaskBan(t *testing.T) { opSID := createSession(ctx, t, env.db, "op") - created, err := env.svc.JoinChannel(ctx, opSID, "op", channel, "") + created, err := env.svc.JoinChannel(ctx, opSID, 0, "op", channel, "") if err != nil { t.Fatalf("join op: %v", err) } @@ -773,7 +773,7 @@ func TestJoinRefusedByHostmaskBan(t *testing.T) { t.Fatalf("create session guest: %v", err) } - _, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "") + _, err = env.svc.JoinChannel(ctx, guestSID, 0, "guest", channel, "") var ircErr *service.IRCError if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrBannedFromChan { @@ -793,17 +793,17 @@ func TestPartDeletesChannelOnlyWhenEmpty(t *testing.T) { aliceSID := createSession(ctx, t, env.db, "alice") bobSID := createSession(ctx, t, env.db, "bob") - _, err := env.svc.JoinChannel(ctx, aliceSID, "alice", channel, "") + _, err := env.svc.JoinChannel(ctx, aliceSID, 0, "alice", channel, "") if err != nil { t.Fatalf("join alice: %v", err) } - _, err = env.svc.JoinChannel(ctx, bobSID, "bob", channel, "") + _, err = env.svc.JoinChannel(ctx, bobSID, 0, "bob", channel, "") if err != nil { t.Fatalf("join bob: %v", err) } - err = env.svc.PartChannel(ctx, aliceSID, "alice", channel, "") + err = env.svc.PartChannel(ctx, aliceSID, 0, "alice", channel, "") if err != nil { t.Fatalf("part alice: %v", err) } @@ -818,7 +818,7 @@ func TestPartDeletesChannelOnlyWhenEmpty(t *testing.T) { t.Fatal("bob is not on the channel after alice parted") } - err = env.svc.PartChannel(ctx, bobSID, "bob", channel, "") + err = env.svc.PartChannel(ctx, bobSID, 0, "bob", channel, "") if err != nil { t.Fatalf("part bob: %v", err) } @@ -839,12 +839,12 @@ func TestKickOfLastMemberDeletesChannel(t *testing.T) { sid := createSession(ctx, t, env.db, "alice") - _, err := env.svc.JoinChannel(ctx, sid, "alice", channel, "") + _, err := env.svc.JoinChannel(ctx, sid, 0, "alice", channel, "") if err != nil { t.Fatalf("join alice: %v", err) } - err = env.svc.KickUser(ctx, sid, "alice", channel, "alice", "") + err = env.svc.KickUser(ctx, sid, 0, "alice", channel, "alice", "") if err != nil { t.Fatalf("kick alice: %v", err) } @@ -863,14 +863,14 @@ func TestSendChannelMessage(t *testing.T) { sid2 := createSession(ctx, t, env.db, "bob") _, err := env.svc.JoinChannel( - ctx, sid1, "alice", "#chat", "", + ctx, sid1, 0, "alice", "#chat", "", ) if err != nil { t.Fatalf("join alice: %v", err) } _, err = env.svc.JoinChannel( - ctx, sid2, "bob", "#chat", "", + ctx, sid2, 0, "bob", "#chat", "", ) if err != nil { t.Fatalf("join bob: %v", err) @@ -878,18 +878,14 @@ func TestSendChannelMessage(t *testing.T) { body, _ := json.Marshal([]string{"hello world"}) //nolint:errchkjson - dbID, uuid, err := env.svc.SendChannelMessage( - ctx, sid1, "alice", + uuid, err := env.svc.SendChannelMessage( + ctx, sid1, 0, "alice", irc.CmdPrivmsg, "#chat", body, nil, ) if err != nil { t.Fatalf("SendChannelMessage: %v", err) } - if dbID == 0 { - t.Error("expected non-zero dbID") - } - if uuid == "" { t.Error("expected non-empty UUID") } @@ -897,8 +893,8 @@ func TestSendChannelMessage(t *testing.T) { // Non-member cannot send. sid3 := createSession(ctx, t, env.db, "charlie") - _, _, err = env.svc.SendChannelMessage( - ctx, sid3, "charlie", + _, err = env.svc.SendChannelMessage( + ctx, sid3, 0, "charlie", irc.CmdPrivmsg, "#chat", body, nil, ) if err == nil { @@ -914,14 +910,14 @@ func TestBroadcastQuit(t *testing.T) { sid2 := createSession(ctx, t, env.db, "bob") _, err := env.svc.JoinChannel( - ctx, sid1, "alice", "#room", "", + ctx, sid1, 0, "alice", "#room", "", ) if err != nil { t.Fatalf("join alice: %v", err) } _, err = env.svc.JoinChannel( - ctx, sid2, "bob", "#room", "", + ctx, sid2, 0, "bob", "#room", "", ) if err != nil { t.Fatalf("join bob: %v", err) @@ -963,7 +959,7 @@ func TestSendDirectMessageTargetQuits(t *testing.T) { body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson _, err = env.svc.SendDirectMessage( - ctx, sid, "alice", irc.CmdPrivmsg, "bob", body, nil, + ctx, sid, 0, "alice", irc.CmdPrivmsg, "bob", body, nil, ) var ircErr *service.IRCError @@ -983,7 +979,7 @@ func TestSendChannelMessageChannelDeleted(t *testing.T) { sid := createSession(ctx, t, env.db, "alice") - _, err := env.svc.JoinChannel(ctx, sid, "alice", "#gone", "") + _, err := env.svc.JoinChannel(ctx, sid, 0, "alice", "#gone", "") if err != nil { t.Fatalf("join alice: %v", err) } @@ -1000,8 +996,8 @@ func TestSendChannelMessageChannelDeleted(t *testing.T) { body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson - _, _, err = env.svc.SendChannelMessage( - ctx, sid, "alice", irc.CmdPrivmsg, "#gone", body, nil, + _, err = env.svc.SendChannelMessage( + ctx, sid, 0, "alice", irc.CmdPrivmsg, "#gone", body, nil, ) var ircErr *service.IRCError @@ -1019,14 +1015,14 @@ func TestSendChannelMessage_Moderated(t *testing.T) { sid2 := createSession(ctx, t, env.db, "bob") result, err := env.svc.JoinChannel( - ctx, sid1, "alice", "#modchat", "", + ctx, sid1, 0, "alice", "#modchat", "", ) if err != nil { t.Fatalf("join alice: %v", err) } _, err = env.svc.JoinChannel( - ctx, sid2, "bob", "#modchat", "", + ctx, sid2, 0, "bob", "#modchat", "", ) if err != nil { t.Fatalf("join bob: %v", err) @@ -1039,8 +1035,8 @@ func TestSendChannelMessage_Moderated(t *testing.T) { body, _ := json.Marshal([]string{"test"}) //nolint:errchkjson // Bob (non-op, non-voiced) should fail to send. - _, _, err = env.svc.SendChannelMessage( - ctx, sid2, "bob", + _, err = env.svc.SendChannelMessage( + ctx, sid2, 0, "bob", irc.CmdPrivmsg, "#modchat", body, nil, ) if err == nil { @@ -1048,8 +1044,8 @@ func TestSendChannelMessage_Moderated(t *testing.T) { } // Alice (operator) should succeed. - _, _, err = env.svc.SendChannelMessage( - ctx, sid1, "alice", + _, err = env.svc.SendChannelMessage( + ctx, sid1, 0, "alice", irc.CmdPrivmsg, "#modchat", body, nil, ) if err != nil { @@ -1074,7 +1070,7 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) { refuse func(ctx context.Context, env *testEnv, chID, bobSID int64) error }{ {"not a member", func(ctx context.Context, env *testEnv, _, bobSID int64) error { - return env.svc.PartChannel(ctx, bobSID, "bob", channel, "") + return env.svc.PartChannel(ctx, bobSID, 0, "bob", channel, "") }}, {"banned", func(ctx context.Context, env *testEnv, chID, _ int64) error { return env.db.AddChannelBan(ctx, chID, "bob!*@*", "alice") @@ -1094,12 +1090,12 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) { aliceSID := createSession(ctx, t, env.db, "alice") bobSID := createSession(ctx, t, env.db, "bob") - joined, err := env.svc.JoinChannel(ctx, aliceSID, "alice", channel, "") + joined, err := env.svc.JoinChannel(ctx, aliceSID, 0, "alice", channel, "") if err != nil { t.Fatalf("join alice: %v", err) } - _, err = env.svc.JoinChannel(ctx, bobSID, "bob", channel, "") + _, err = env.svc.JoinChannel(ctx, bobSID, 0, "bob", channel, "") if err != nil { t.Fatalf("join bob: %v", err) } @@ -1119,8 +1115,8 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) { ) meta, _ := json.Marshal(map[string]string{"hashcash": stamp}) - _, _, err = env.svc.SendChannelMessage( - ctx, bobSID, "bob", irc.CmdPrivmsg, channel, body, meta, + _, err = env.svc.SendChannelMessage( + ctx, bobSID, 0, "bob", irc.CmdPrivmsg, channel, body, meta, ) var ircErr *service.IRCError @@ -1129,8 +1125,8 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) { t.Fatalf("bob: want ERR_CANNOTSENDTOCHAN, got %v", err) } - _, _, err = env.svc.SendChannelMessage( - ctx, aliceSID, "alice", irc.CmdPrivmsg, channel, body, meta, + _, err = env.svc.SendChannelMessage( + ctx, aliceSID, 0, "alice", irc.CmdPrivmsg, channel, body, meta, ) if err != nil { t.Fatalf("alice with the same stamp: %v", err) -- 2.54.0 From d31fd529e278bd4e711328e3eb3cce9537cba805 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 21 Oct 2026 17:56:02 +0200 Subject: [PATCH 40/40] Make an IRC listener connection follow its session's nick (closes #184) An IRC listener connection kept its own copy of its nick, so after the session's nick was changed from another client it went on sending under the old nick, which another user could then take. Now, whenever the relay passes on a NICK line and after the connection's own NICK is stored, the connection takes the nick its session holds in the database, read by session ID under the connection's lock. Taking the nick from the database rather than from the line means a connection can never follow another user's rename and send as them. Handlers read the nick under the same lock. Untested: an HTTP API rename landing while the connection's own NICK is being stored; that order cannot be produced reliably. Model: opus-5-5 --- internal/db/queries.go | 19 ++++ internal/ircserver/commands.go | 29 +++--- internal/ircserver/conn.go | 26 +++++- internal/ircserver/relay.go | 13 ++- internal/ircserver/relay_test.go | 148 +++++++++++++++++++++++++------ 5 files changed, 187 insertions(+), 48 deletions(-) diff --git a/internal/db/queries.go b/internal/db/queries.go index 11aa6f3..e83e2a0 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -264,6 +264,25 @@ func (database *Database) GetSessionByNick( return sessionID, nil } +// GetSessionNick returns the nick a session holds. +func (database *Database) GetSessionNick( + ctx context.Context, + sessionID int64, +) (string, error) { + var nick string + + err := database.conn.QueryRowContext( + ctx, + "SELECT nick FROM sessions WHERE id = ?", + sessionID, + ).Scan(&nick) + if err != nil { + return "", fmt.Errorf("get session nick: %w", err) + } + + return nick, nil +} + // SessionHostInfo holds the username, hostname, and IP // for a session. type SessionHostInfo struct { diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index 3972026..43e00e5 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -81,7 +81,7 @@ func (c *Conn) handleNick( } oldMask := c.hostmask() - oldNick := c.nick + oldNick := c.currentNick() err := c.svc.ChangeNick( ctx, c.sessionID, c.clientID, oldNick, newNick, @@ -92,9 +92,10 @@ func (c *Conn) handleNick( return } - c.mu.Lock() - c.nick = newNick - c.mu.Unlock() + // Take the nick the session holds, not newNick: another + // client of the session may have changed it again since + // ChangeNick stored newNick. + c.loadSessionNick(ctx) // Echo NICK change to the client on wire. c.send(FormatMessage(oldMask, "NICK", newNick)) @@ -129,7 +130,7 @@ func (c *Conn) handlePrivmsg( if strings.HasPrefix(target, "#") { _, err := c.svc.SendChannelMessage( - ctx, c.sessionID, c.clientID, c.nick, + ctx, c.sessionID, c.clientID, c.currentNick(), msg.Command, target, body, nil, ) if err != nil { @@ -137,7 +138,7 @@ func (c *Conn) handlePrivmsg( } } else { result, err := c.svc.SendDirectMessage( - ctx, c.sessionID, c.clientID, c.nick, + ctx, c.sessionID, c.clientID, c.currentNick(), msg.Command, target, body, nil, ) if err != nil { @@ -199,7 +200,7 @@ func (c *Conn) joinChannel( channel, key string, ) { result, err := c.svc.JoinChannel( - ctx, c.sessionID, c.clientID, c.nick, channel, key, + ctx, c.sessionID, c.clientID, c.currentNick(), channel, key, ) if err != nil { c.sendIRCError(err) @@ -337,7 +338,7 @@ func (c *Conn) partChannel( channel, reason string, ) { err := c.svc.PartChannel( - ctx, c.sessionID, c.clientID, c.nick, channel, reason, + ctx, c.sessionID, c.clientID, c.currentNick(), channel, reason, ) if err != nil { c.sendIRCError(err) @@ -401,7 +402,7 @@ func (c *Conn) handleTopic( newTopic := msg.Params[1] err := c.svc.SetTopic( - ctx, c.sessionID, c.clientID, c.nick, channel, newTopic, + ctx, c.sessionID, c.clientID, c.currentNick(), channel, newTopic, ) if err != nil { c.sendIRCError(err) @@ -708,7 +709,7 @@ func (c *Conn) applyChannelModes( c.send(FormatMessage(c.hostmask(), "MODE", params...)) c.svc.BroadcastMode( - ctx, c.clientID, c.nick, channel, chID, modeReply, + ctx, c.clientID, c.currentNick(), channel, chID, modeReply, ) } } @@ -1185,7 +1186,7 @@ func (c *Conn) handleKick( } err := c.svc.KickUser( - ctx, c.sessionID, c.clientID, c.nick, + ctx, c.sessionID, c.clientID, c.currentNick(), channel, targetNick, reason, ) if err != nil { @@ -1225,7 +1226,7 @@ func (c *Conn) handlePassPostReg( return } - c.sendFromServer("NOTICE", c.nick, + c.sendFromServer("NOTICE", c.currentNick(), "Password set. You can reconnect using "+ "PASS with your nick.", ) @@ -1249,7 +1250,7 @@ func (c *Conn) handleInvite( channel := msg.Params[1] targetID, err := c.svc.InviteUser( - ctx, c.sessionID, c.nick, targetNick, channel, + ctx, c.sessionID, c.currentNick(), targetNick, channel, ) if err != nil { c.sendIRCError(err) @@ -1266,7 +1267,7 @@ func (c *Conn) handleInvite( body, _ := json.Marshal([]string{channel}) //nolint:errchkjson _, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast - ctx, "INVITE", c.nick, targetNick, targetID, 0, + ctx, "INVITE", c.currentNick(), targetNick, targetID, 0, nil, body, nil, []int64{targetID}, 0, ) } diff --git a/internal/ircserver/conn.go b/internal/ircserver/conn.go index dbbd0e5..3dcca86 100644 --- a/internal/ircserver/conn.go +++ b/internal/ircserver/conn.go @@ -313,6 +313,22 @@ func (c *Conn) currentNick() string { 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. func (c *Conn) send(line string) { c.sendWithin(writeTimeout, line) @@ -345,7 +361,7 @@ func (c *Conn) sendNumeric( code irc.IRCMessageType, params ...string, ) { - nick := c.nick + nick := c.currentNick() if nick == "" { nick = "*" } @@ -369,9 +385,11 @@ func (c *Conn) sendFromServer( // hostmask returns the client's full hostmask // (nick!user@host). func (c *Conn) hostmask() string { + nick := c.currentNick() + user := c.username if user == "" { - user = c.nick + user = nick } host := c.hostname @@ -379,7 +397,7 @@ func (c *Conn) hostmask() string { host = c.remoteIP } - return c.nick + "!" + user + "@" + host + return nick + "!" + user + "@" + host } // handleMessage dispatches a parsed IRC message using @@ -657,7 +675,7 @@ func (c *Conn) checkPasswordLength(pw string) bool { return true } - c.sendFromServer("NOTICE", c.nick, + c.sendFromServer("NOTICE", c.currentNick(), "Password must be 8 to 72 bytes", ) diff --git a/internal/ircserver/relay.go b/internal/ircserver/relay.go index 581fb7f..2275adc 100644 --- a/internal/ircserver/relay.go +++ b/internal/ircserver/relay.go @@ -92,7 +92,7 @@ func (c *Conn) drainQueue(ctx context.Context) bool { // //nolint:cyclop // dispatch table func (c *Conn) deliverIRCMessage( - _ context.Context, + ctx context.Context, msg *db.IRCMessage, ) { command := msg.Command @@ -120,7 +120,7 @@ func (c *Conn) deliverIRCMessage( case command == irc.CmdPart: c.deliverPart(msg, text) case command == irc.CmdNick: - c.deliverNickChange(msg, text) + c.deliverNickChange(ctx, msg, text) case command == irc.CmdQuit: c.deliverQuitMsg(msg, text) case command == irc.CmdTopic: @@ -230,11 +230,18 @@ func (c *Conn) deliverPart(msg *db.IRCMessage, text string) { } } -// deliverNickChange sends a NICK change notification. +// deliverNickChange sends a NICK change notification. The +// 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( + ctx context.Context, msg *db.IRCMessage, newNick string, ) { + c.loadSessionNick(ctx) + prefix := msg.From + "!" + msg.From + "@*" c.send(FormatMessage(prefix, "NICK", newNick)) diff --git a/internal/ircserver/relay_test.go b/internal/ircserver/relay_test.go index 71affec..a40c281 100644 --- a/internal/ircserver/relay_test.go +++ b/internal/ircserver/relay_test.go @@ -511,13 +511,10 @@ func TestRelayOwnSessionOnce(t *testing.T) { } } -// ownSessionCopies 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 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. +// 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, @@ -525,27 +522,9 @@ func ownSessionCopies( ) (int, int) { t.Helper() - const password = "alicepass" - ctx := t.Context() env := newTestEnv(t) - - 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( - ctx, "alice", password, "127.0.0.1", "localhost", - ) - if err != nil { - t.Fatalf("log in as alice: %v", err) - } + alice, _, sessionID, httpClientID := aliceWithHTTPClient(t, env) var lines []string @@ -555,7 +534,7 @@ func ownSessionCopies( // Her echo comes before the reply to her next command. lines = alice.sendAndExpect("PING :done", "PONG") } else { - err = api(ctx, env.srv.Service(), sessionID) + err := api(ctx, env.srv.Service(), sessionID) if err != nil { t.Fatalf("%s over the HTTP API: %v", line, err) } @@ -601,3 +580,118 @@ func ownSessionCopies( 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) + } +} -- 2.54.0