Author SHA1 Message Date
clawbot 60c0d4b5e3 Re-vendor the canonical files from sneak/prompts at dd4027b (closes #112)
check / check (push) Waiting to run
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.

Model: opus-5-5
2026-10-06 08:35:46 +00:00
clawbot 9a46421902 Run make test once so a failing test fails the build (closes #101)
check / check (push) Successful in 4m31s
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
2026-10-06 08:31:01 +02:00
clawbot 729c671561 Remove BUILDARCH and stamp the git version in Docker builds (closes #107, closes #109)
check / check (push) Successful in 2m1s
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
2026-10-02 04:13:07 +02:00
clawbot 915f56ee02 Fix the data races and the test timeout that fail make test (closes #104)
check / check (push) Successful in 3s
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
(#101).

Model: opus-5-5
2026-10-02 02:49:53 +02:00
54 changed files with 2896 additions and 5383 deletions
+80 -9
View File
@@ -1,9 +1,80 @@
.git
*.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
+5
View File
@@ -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
+1 -1
View File
@@ -6,4 +6,4 @@ jobs:
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: docker build .
- run: script/cibuild
+37 -10
View File
@@ -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
+73 -10
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
node_modules/
yarn.lock
+4
View File
@@ -0,0 +1,4 @@
{
"tabWidth": 4,
"proseWrap": "always"
}
+6 -4
View File
@@ -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.
+21 -15
View File
@@ -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
+48 -20
View File
@@ -8,42 +8,70 @@ 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
# golang:1.24-alpine, 2026-02-26
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
# 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.
# -p 4 runs at most four test binaries at once: under -race each one
# costs a few hundred MB, and the default is one per core.
# golang:1.24.13-bookworm, 2026-10-06
FROM golang@sha256:1a6d4452c65dea36aac2e2d606b01b4a029ec90cc1ae53890540ce6173ea77ac AS test
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 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
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
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)
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 `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 /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 \
+24 -23
View File
@@ -1,16 +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")
BUILDARCH := $(shell go env GOARCH)
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
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 && \
@@ -21,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 30s -race -cover ./... || go test -timeout 30s -race -v ./...
# 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)
@@ -51,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
+972 -868
View File
File diff suppressed because it is too large Load Diff
+513 -29
View File
@@ -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@<version> --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/<name>`. 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=<phase> /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:
@<test-command> || \
{ echo "--- Rerunning with -v for details ---"; \
<test-command-with-v>; 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 <tool>; 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 <package>@<commit hash>`). 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.
-1
View File
@@ -1,4 +1,3 @@
// Package db provides database access and migration management.
package db
import (
-16
View File
@@ -58,19 +58,3 @@ func (database *Database) Close() error {
return nil
}
// ExecForTest runs a raw statement against the test
// database. Tests use it to install SQLite triggers that
// force a specific write to fail, so that the atomicity of
// multi-statement helpers can be exercised.
func (database *Database) ExecForTest(
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
}
+22 -215
View File
@@ -7,7 +7,6 @@ import (
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
@@ -1151,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()
}
}
@@ -1374,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,
)
@@ -1383,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,
)
@@ -1906,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,
)
@@ -1917,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,
)
@@ -2248,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,
)
@@ -2261,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,
)
@@ -2309,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,
)
@@ -2322,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,
)
@@ -2414,205 +2423,3 @@ func (database *Database) SetChannelUserLimit(
return nil
}
// SetSessionUserModes applies a set of user-mode flag
// changes to a session inside a single transaction, so a
// multi-mode change such as "+w-o" is all-or-nothing. A nil
// pointer means the caller did not mention that mode and
// the stored value must be left untouched.
func (database *Database) SetSessionUserModes(
ctx context.Context,
sessionID int64,
wallops *bool,
oper *bool,
) error {
if wallops == nil && oper == nil {
return nil
}
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
if wallops != nil {
if _, err := transaction.ExecContext(
ctx,
`UPDATE sessions SET is_wallops = ? WHERE id = ?`,
boolToInt(*wallops), sessionID,
); err != nil {
_ = transaction.Rollback()
return fmt.Errorf(
"set session wallops: %w", err,
)
}
}
if oper != nil {
if _, err := transaction.ExecContext(
ctx,
`UPDATE sessions SET is_oper = ? WHERE id = ?`,
boolToInt(*oper), sessionID,
); err != nil {
_ = transaction.Rollback()
return fmt.Errorf("set session oper: %w", err)
}
}
if err := transaction.Commit(); err != nil {
_ = transaction.Rollback()
return fmt.Errorf("commit user modes: %w", err)
}
return nil
}
// boolToInt renders a Go bool as the 0/1 integer used for
// boolean columns in the SQLite schema.
func boolToInt(value bool) int {
if value {
return 1
}
return 0
}
// SetSessionWallops sets the wallops (+w) flag on a
// session.
func (database *Database) SetSessionWallops(
ctx context.Context,
sessionID int64,
enabled bool,
) error {
val := 0
if enabled {
val = 1
}
_, err := database.conn.ExecContext(
ctx,
`UPDATE sessions SET is_wallops = ? WHERE id = ?`,
val, sessionID,
)
if err != nil {
return fmt.Errorf("set session wallops: %w", err)
}
return nil
}
// 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
if scanErr := rows.Scan(&sessionID); scanErr != nil {
return nil, fmt.Errorf(
"scan wallops session: %w", scanErr,
)
}
ids = append(ids, sessionID)
}
if err := rows.Err(); 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 omitted from the
// result; any other database failure is returned, because
// an unreadable row is not the same as an absent one.
func (database *Database) GetUserhostInfo(
ctx context.Context,
nicks []string,
) ([]UserhostInfo, error) {
if len(nicks) == 0 {
return nil, nil
}
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 err != nil {
if errors.Is(err, sql.ErrNoRows) {
continue // nick not online
}
return nil, fmt.Errorf(
"userhost lookup %q: %w", nick, err,
)
}
results = append(results, info)
}
return results, nil
}
+9 -130
View File
@@ -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 {
@@ -1488,127 +1491,3 @@ func TestChannelUserLimit(t *testing.T) {
t.Fatalf("expected 0, got %d", limit)
}
}
// TestSetSessionUserModesIsAtomic proves that a multi-mode
// change is all-or-nothing. A trigger makes the is_oper
// UPDATE fail after the is_wallops UPDATE has already run,
// which is exactly the "+w-o" partial-failure the previous
// implementation exhibited: it issued the two UPDATEs
// independently, so +w persisted while the caller reported
// total failure.
func TestSetSessionUserModesIsAtomic(t *testing.T) {
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 persisted despite the transaction " +
"failing; the apply stage is not atomic",
)
}
}
// TestSetSessionUserModesAppliesBoth is the success-path
// counterpart: when nothing fails, both flags are written,
// and a nil pointer leaves that flag untouched.
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)
}
if err := database.SetSessionOper(
ctx, sessionID, true,
); err != nil {
t.Fatal(err)
}
wallops := true
oper := false
if err := database.SetSessionUserModes(
ctx, sessionID, &wallops, &oper,
); 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,
)
}
// A nil pointer must leave the stored value alone.
if err := database.SetSessionUserModes(
ctx, sessionID, nil, nil,
); err != nil {
t.Fatal(err)
}
gotWallops, err = database.IsSessionWallops(
ctx, sessionID,
)
if err != nil {
t.Fatal(err)
}
if !gotWallops {
t.Error("nil pointers must not clear wallops")
}
}
-1
View File
@@ -10,7 +10,6 @@ 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 '',
+82 -110
View File
@@ -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)
}
@@ -969,12 +962,10 @@ func (hdlr *Handlers) dispatchCommand(
bodyLines func() []string,
) {
switch command {
case irc.CmdAway, irc.CmdNick,
irc.CmdPass, irc.CmdInvite:
hdlr.dispatchBodyOnlyCommand(
case irc.CmdAway:
hdlr.handleAway(
writer, request,
sessionID, clientID, nick,
command, bodyLines,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdPrivmsg, irc.CmdNotice:
hdlr.handlePrivmsg(
@@ -993,12 +984,27 @@ func (hdlr *Handlers) dispatchCommand(
writer, request,
sessionID, clientID, nick, target, body,
)
case irc.CmdNick:
hdlr.handleNick(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdPass:
hdlr.handlePass(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdTopic:
hdlr.handleTopic(
writer, request,
sessionID, clientID, nick,
target, body, bodyLines,
)
case irc.CmdInvite:
hdlr.handleInvite(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdKick:
hdlr.handleKick(
writer, request,
@@ -1009,15 +1015,12 @@ func (hdlr *Handlers) dispatchCommand(
hdlr.handleQuit(
writer, request, sessionID, nick, body,
)
case irc.CmdOper, irc.CmdKill, irc.CmdWallops:
hdlr.dispatchOperCommand(
case irc.CmdOper:
hdlr.handleOper(
writer, request,
sessionID, clientID, nick,
command, bodyLines,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdMotd, irc.CmdPing,
irc.CmdVersion, irc.CmdAdmin,
irc.CmdInfo, irc.CmdTime:
case irc.CmdMotd, irc.CmdPing:
hdlr.dispatchInfoCommand(
writer, request,
sessionID, clientID, nick,
@@ -1072,11 +1075,6 @@ func (hdlr *Handlers) dispatchQueryCommand(
writer, request,
sessionID, clientID, nick,
)
case irc.CmdUserhost:
hdlr.handleUserhost(
writer, request,
sessionID, clientID, nick, bodyLines,
)
default:
hdlr.enqueueNumeric(
request.Context(), clientID,
@@ -1085,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)
}
}
@@ -1107,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
@@ -1122,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
@@ -1164,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)
}
@@ -1252,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)
}
@@ -1442,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)
}
@@ -1517,7 +1515,7 @@ func (hdlr *Handlers) executeJoin(
hdlr.respondJSON(writer, request,
map[string]string{
"status": "joined",
statusKey: "joined",
"channel": channel,
},
http.StatusOK)
@@ -1674,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 &&
@@ -1695,7 +1694,7 @@ func (hdlr *Handlers) handlePart(
hdlr.respondJSON(writer, request,
map[string]string{
"status": "parted",
statusKey: "parted",
"channel": channel,
},
http.StatusOK)
@@ -1734,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)
@@ -1765,7 +1764,7 @@ func (hdlr *Handlers) executeNickChange(
hdlr.respondJSON(writer, request,
map[string]string{
"status": "ok", "nick": newNick,
statusKey: "ok", nickKey: newNick,
},
http.StatusOK)
}
@@ -1826,7 +1825,7 @@ func (hdlr *Handlers) handleTopic(
hdlr.respondJSON(writer, request,
map[string]string{
"status": "ok", "topic": topic,
statusKey: "ok", "topic": topic,
},
http.StatusOK)
}
@@ -1869,8 +1868,7 @@ func (hdlr *Handlers) deliverSetTopicNumerics(
}
// dispatchInfoCommand handles informational IRC commands
// that produce server-side numerics (MOTD, PING,
// VERSION, ADMIN, INFO, TIME).
// that produce server-side numerics (MOTD, PING).
func (hdlr *Handlers) dispatchInfoCommand(
writer http.ResponseWriter,
request *http.Request,
@@ -1881,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:
@@ -1896,34 +1894,6 @@ func (hdlr *Handlers) dispatchInfoCommand(
},
http.StatusOK)
return
case irc.CmdVersion:
hdlr.handleVersion(
writer, request,
sessionID, clientID, nick,
)
return
case irc.CmdAdmin:
hdlr.handleAdmin(
writer, request,
sessionID, clientID, nick,
)
return
case irc.CmdInfo:
hdlr.handleInfo(
writer, request,
sessionID, clientID, nick,
)
return
case irc.CmdTime:
hdlr.handleTime(
writer, request,
sessionID, clientID, nick,
)
return
}
@@ -1940,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 &&
@@ -1952,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)
}
@@ -1980,11 +1951,15 @@ func (hdlr *Handlers) handleMode(
channel := target
if !strings.HasPrefix(channel, "#") {
hdlr.handleUserMode(
writer, request,
sessionID, clientID, nick, target,
bodyLines,
// User mode query — return empty modes.
hdlr.enqueueNumeric(
request.Context(), clientID,
irc.RplUmodeIs, nick, nil, "+",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"},
http.StatusOK)
return
}
@@ -2084,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)
}
@@ -2261,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)
}
}
@@ -2327,7 +2302,7 @@ func (hdlr *Handlers) applyUserMode(
)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
map[string]string{statusKey: "ok"},
http.StatusOK)
}
@@ -2373,7 +2348,7 @@ func (hdlr *Handlers) setChannelFlag(
)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
map[string]string{statusKey: "ok"},
http.StatusOK)
}
@@ -2457,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)
}
@@ -2506,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)
}
@@ -2611,7 +2586,7 @@ func (hdlr *Handlers) executeBanChange(
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
map[string]string{statusKey: "ok"},
http.StatusOK)
}
@@ -2665,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)
}
@@ -2729,7 +2704,7 @@ func (hdlr *Handlers) setChannelKeyMode(
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
map[string]string{statusKey: "ok"},
http.StatusOK)
}
@@ -2778,7 +2753,7 @@ func (hdlr *Handlers) clearChannelKeyMode(
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
map[string]string{statusKey: "ok"},
http.StatusOK)
}
@@ -2853,7 +2828,7 @@ func (hdlr *Handlers) setChannelLimitMode(
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
map[string]string{statusKey: "ok"},
http.StatusOK)
}
@@ -2903,7 +2878,7 @@ func (hdlr *Handlers) clearChannelLimitMode(
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
map[string]string{statusKey: "ok"},
http.StatusOK)
}
@@ -3070,7 +3045,7 @@ func (hdlr *Handlers) executeInvite(
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
map[string]string{statusKey: "ok"},
http.StatusOK)
}
@@ -3139,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)
}
@@ -3192,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)
}
@@ -3282,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)
}
@@ -3307,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)
}
@@ -3474,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
@@ -3516,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)
}
@@ -3532,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)
}
@@ -3723,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)
}
}
@@ -3829,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)
}
@@ -3877,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)
}
@@ -3939,7 +3914,7 @@ func (hdlr *Handlers) handleKick(
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
map[string]string{statusKey: "ok"},
http.StatusOK)
}
@@ -3963,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,
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -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)
}
+9 -1
View File
@@ -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,
)
}
-576
View File
@@ -1,576 +0,0 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc"
)
// maxUserhostNicks is the maximum number of nicks allowed
// in a single USERHOST query (RFC 2812).
const maxUserhostNicks = 5
// dispatchBodyOnlyCommand routes commands that take
// (writer, request, sessionID, clientID, nick, bodyLines).
func (hdlr *Handlers) dispatchBodyOnlyCommand(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, command string,
bodyLines func() []string,
) {
switch command {
case irc.CmdAway:
hdlr.handleAway(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdNick:
hdlr.handleNick(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdPass:
hdlr.handlePass(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdInvite:
hdlr.handleInvite(
writer, request,
sessionID, clientID, nick, bodyLines,
)
}
}
// dispatchOperCommand routes oper-related commands (OPER,
// KILL, WALLOPS) to their handlers.
func (hdlr *Handlers) dispatchOperCommand(
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 the USERHOST command.
// Returns user@host info for up to 5 nicks.
func (hdlr *Handlers) handleUserhost(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
ctx := request.Context()
lines := bodyLines()
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdUserhost},
"Not enough parameters",
)
return
}
// Limit to 5 nicks per RFC 2812.
nicks := lines
if len(nicks) > maxUserhostNicks {
nicks = nicks[:maxUserhostNicks]
}
infos, err := hdlr.params.Database.GetUserhostInfo(
ctx, nicks,
)
if err != nil {
hdlr.log.Error(
"userhost query failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
replyStr := hdlr.buildUserhostReply(infos)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUserHost, nick, nil,
replyStr,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// buildUserhostReply builds the RPL_USERHOST reply
// string per RFC 2812.
func (hdlr *Handlers) buildUserhostReply(
infos []db.UserhostInfo,
) string {
replies := make([]string, 0, len(infos))
for idx := range infos {
info := &infos[idx]
username := info.Username
if username == "" {
username = info.Nick
}
hostname := info.Hostname
if hostname == "" {
hostname = hdlr.serverName()
}
operStar := ""
if info.IsOper {
operStar = "*"
}
awayPrefix := "+"
if info.AwayMessage != "" {
awayPrefix = "-"
}
replies = append(replies,
info.Nick+operStar+"="+
awayPrefix+username+"@"+hostname,
)
}
return strings.Join(replies, " ")
}
// handleVersion handles the VERSION command.
// Returns the server version string.
func (hdlr *Handlers) handleVersion(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
version := hdlr.svc.ServerVersion()
// 351 RPL_VERSION
hdlr.enqueueNumeric(
ctx, clientID, irc.RplVersion, nick,
[]string{version + ".", srvName},
"",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleAdmin handles the ADMIN command.
// Returns server admin contact info.
func (hdlr *Handlers) handleAdmin(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
// 256 RPL_ADMINME
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminMe, nick,
[]string{srvName},
"Administrative info",
)
// 257 RPL_ADMINLOC1
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc1, nick, nil,
"neoirc server",
)
// 258 RPL_ADMINLOC2
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc2, nick, nil,
"IRC over HTTP",
)
// 259 RPL_ADMINEMAIL
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminEmail, nick, nil,
"admin@"+srvName,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleInfo handles the INFO command.
// Returns server software information.
func (hdlr *Handlers) handleInfo(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
for _, line := range hdlr.svc.InfoLines() {
// 371 RPL_INFO
hdlr.enqueueNumeric(
ctx, clientID, irc.RplInfo, nick, nil,
line,
)
}
// 374 RPL_ENDOFINFO
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfInfo, nick, nil,
"End of /INFO list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleTime handles the TIME command.
// Returns the server's local time in RFC format.
func (hdlr *Handlers) handleTime(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
// 391 RPL_TIME
hdlr.enqueueNumeric(
ctx, clientID, irc.RplTime, nick,
[]string{srvName},
time.Now().Format(time.RFC1123),
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleKill handles the KILL command.
// Forcibly disconnects a user (oper only).
func (hdlr *Handlers) handleKill(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
ctx := request.Context()
// Check oper status.
isOper, err := hdlr.params.Database.IsSessionOper(
ctx, sessionID,
)
if err != nil || !isOper {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoPrivileges, nick, nil,
"Permission Denied- You're not an IRC operator",
)
return
}
lines := bodyLines()
var targetNick string
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 := "KILLed"
if len(lines) > 1 {
reason = lines[1]
}
targetSID, lookupErr := hdlr.params.Database.
GetSessionByNick(ctx, targetNick)
if lookupErr != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoSuchNick, nick,
[]string{targetNick},
"No such nick/channel",
)
return
}
// Do not allow killing yourself.
if targetSID == sessionID {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCantKillServer, nick, nil,
"You cannot KILL yourself",
)
return
}
quitReason := "Killed (" + nick + " (" + reason + "))"
// KillSession broadcasts the QUIT, deletes the session
// and disconnects the victim's wire connection if it
// holds one.
hdlr.svc.KillSession(
ctx, targetSID, targetNick, quitReason,
)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleWallops handles the WALLOPS command.
// Broadcasts a message to all users with +w usermode
// (oper only).
func (hdlr *Handlers) handleWallops(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
ctx := request.Context()
// Check oper status.
isOper, err := hdlr.params.Database.IsSessionOper(
ctx, sessionID,
)
if err != nil || !isOper {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoPrivileges, nick, nil,
"Permission Denied- You're not an IRC operator",
)
return
}
lines := bodyLines()
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdWallops},
"Not enough parameters",
)
return
}
message := strings.Join(lines, " ")
wallopsSIDs, err := hdlr.params.Database.
GetWallopsSessionIDs(ctx)
if err != nil {
hdlr.log.Error(
"get wallops sessions failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
if len(wallopsSIDs) > 0 {
body, mErr := json.Marshal([]string{message})
if mErr != nil {
hdlr.log.Error(
"marshal wallops body", "error", mErr,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
_ = hdlr.fanOutSilent(
request, irc.CmdWallops, nick, "*",
json.RawMessage(body), wallopsSIDs,
)
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleUserMode handles user mode queries and changes
// (e.g., MODE nick, MODE nick +w). Delegates to the
// shared service.ApplyUserMode / service.QueryUserMode so
// that mode string processing is identical for both the
// HTTP API and IRC wire protocol.
func (hdlr *Handlers) handleUserMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, target string,
bodyLines func() []string,
) {
ctx := request.Context()
// Users can only query or change their own modes. The
// check is above the query/change split so that both
// forms are rejected, and uses EqualFold because IRC
// nicks are case-insensitive — matching the wire path
// in ircserver.handleUserMode.
if target != "" && !strings.EqualFold(target, nick) {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUsersDoNotMatch, nick, nil,
"Can't change mode for other users",
)
return
}
lines := bodyLines()
// Mode change requested.
if len(lines) > 0 {
hdlr.changeUserMode(
writer, request,
sessionID, clientID, nick, lines[0],
)
return
}
// Mode query — delegate to shared service.
modeStr, err := hdlr.svc.QueryUserMode(ctx, sessionID)
if err != nil {
hdlr.log.Error(
"query user mode failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUmodeIs, nick, nil,
modeStr,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// changeUserMode applies a mode string to the caller's own
// session. The caller has already verified that the target
// nick is the caller's own.
func (hdlr *Handlers) changeUserMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, modeStr string,
) {
ctx := request.Context()
newModes, err := hdlr.svc.ApplyUserMode(
ctx, sessionID, modeStr,
)
if err != nil {
var ircErr *service.IRCError
if errors.As(err, &ircErr) {
hdlr.respondIRCError(
writer, request,
clientID, sessionID,
ircErr.Code, nick, ircErr.Params,
ircErr.Message,
)
return
}
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUmodeIs, nick, nil,
newModes,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
File diff suppressed because it is too large Load Diff
+7 -6
View File
@@ -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
}
+13 -243
View File
@@ -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)
}
@@ -349,10 +345,7 @@ 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
@@ -434,7 +427,7 @@ func (c *Conn) handleMode(
if strings.HasPrefix(target, "#") {
c.handleChannelMode(ctx, msg)
} else {
c.handleUserMode(ctx, msg)
c.handleUserMode(msg)
}
}
@@ -622,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
@@ -675,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,
@@ -697,13 +687,10 @@ func (c *Conn) applyChannelModes(
}
// handleUserMode handles MODE for users.
func (c *Conn) handleUserMode(
ctx context.Context,
msg *Message,
) {
func (c *Conn) handleUserMode(msg *Message) {
target := msg.Params[0]
if !strings.EqualFold(target, c.currentNick()) {
if !strings.EqualFold(target, c.nick) {
c.sendNumeric(
irc.ErrUsersDoNotMatch,
"Can't change mode for other users",
@@ -712,48 +699,8 @@ func (c *Conn) handleUserMode(
return
}
// Mode query (no mode string).
if len(msg.Params) < 2 { //nolint:mnd
modes, err := c.svc.QueryUserMode(
ctx, c.sessionID,
)
if err != nil {
c.log.Error(
"query user mode failed", "error", err,
)
c.sendNumeric(
irc.ErrUmodeUnknownFlag,
"Unable to read user modes",
)
return
}
c.sendNumeric(irc.RplUmodeIs, modes)
return
}
newModes, err := c.svc.ApplyUserMode(
ctx, c.sessionID, msg.Params[1],
)
if err != nil {
var ircErr *service.IRCError
if errors.As(err, &ircErr) {
c.sendNumeric(ircErr.Code, ircErr.Message)
return
}
c.sendNumeric(
irc.ErrUmodeUnknownFlag,
"Unknown MODE flag",
)
return
}
c.sendNumeric(irc.RplUmodeIs, newModes)
// We don't support user modes beyond the basics.
c.sendNumeric(irc.RplUmodeIs, "+")
}
// handleNames replies with channel member list.
@@ -1345,180 +1292,3 @@ func (c *Conn) handleUserhost(
strings.Join(replies, " "),
)
}
// handleVersion replies with the server version string.
func (c *Conn) handleVersion() {
c.sendNumeric(
irc.RplVersion,
c.svc.ServerVersion()+".", c.serverSfx,
"",
)
}
// handleAdmin replies with server admin info.
func (c *Conn) handleAdmin() {
srvName := c.serverSfx
c.sendNumeric(
irc.RplAdminMe,
srvName, "Administrative info",
)
c.sendNumeric(
irc.RplAdminLoc1,
"neoirc server",
)
c.sendNumeric(
irc.RplAdminLoc2,
"IRC over HTTP",
)
c.sendNumeric(
irc.RplAdminEmail,
"admin@"+srvName,
)
}
// handleInfo replies with server 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 current time.
func (c *Conn) handleTime() {
srvName := c.serverSfx
c.sendNumeric(
irc.RplTime,
srvName, time.Now().Format(time.RFC1123),
)
}
// handleKillCmd forcibly disconnects a target user (oper
// only).
func (c *Conn) handleKillCmd(
ctx context.Context,
msg *Message,
) {
isOper, err := c.database.IsSessionOper(
ctx, c.sessionID,
)
if err != nil || !isOper {
c.sendNumeric(
irc.ErrNoPrivileges,
"Permission Denied- "+
"You're not an IRC operator",
)
return
}
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
"KILL", "Not enough parameters",
)
return
}
targetNick := msg.Params[0]
reason := "KILLed"
if len(msg.Params) > 1 {
reason = msg.Params[1]
}
killerNick := c.currentNick()
if strings.EqualFold(targetNick, killerNick) {
c.sendNumeric(
irc.ErrCantKillServer,
"You cannot KILL yourself",
)
return
}
targetSID, lookupErr := c.database.GetSessionByNick(
ctx, targetNick,
)
if lookupErr != nil {
c.sendNumeric(
irc.ErrNoSuchNick,
targetNick, "No such nick/channel",
)
return
}
quitReason := "Killed (" + killerNick +
" (" + reason + "))"
// KillSession broadcasts the QUIT, deletes the session
// and disconnects the victim's wire connection.
c.svc.KillSession(
ctx, targetSID, targetNick, quitReason,
)
}
// handleWallopsCmd broadcasts to all +w users (oper only).
func (c *Conn) handleWallopsCmd(
ctx context.Context,
msg *Message,
) {
isOper, err := c.database.IsSessionOper(
ctx, c.sessionID,
)
if err != nil || !isOper {
c.sendNumeric(
irc.ErrNoPrivileges,
"Permission Denied- "+
"You're not an IRC operator",
)
return
}
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
"WALLOPS", "Not enough parameters",
)
return
}
message := msg.Params[0]
wallopsSIDs, wallErr := c.database.
GetWallopsSessionIDs(ctx)
if wallErr != nil {
c.log.Error(
"get wallops sessions failed",
"error", wallErr,
)
return
}
if len(wallopsSIDs) > 0 {
body, mErr := json.Marshal([]string{message})
if mErr != nil {
return
}
_, _, _ = c.svc.FanOut(
ctx, irc.CmdWallops, c.currentNick(), "*",
nil, body, nil, wallopsSIDs,
)
}
}
+10 -141
View File
@@ -22,7 +22,6 @@ 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
@@ -47,12 +46,6 @@ type Conn struct {
serverSfx string
commands map[string]cmdHandler
// writeMu serializes writes to conn. A connection is
// written to by its own read loop, by its relay
// goroutine, and — when an operator KILLs it — by
// another client's goroutine.
writeMu sync.Mutex
mu sync.Mutex
nick string
username string
@@ -69,8 +62,6 @@ type Conn struct {
lastQueueID int64
closed bool
killed bool
cancel context.CancelFunc
}
func newConn(
@@ -106,76 +97,6 @@ func newConn(
return conn
}
// Disconnect terminates the connection on behalf of an
// operator KILL issued from either transport. The victim
// is told why, then its socket is closed so that the read
// loop unblocks and serve() returns; without the close the
// victim would keep a socket that looks alive but silently
// delivers nothing. Disconnect is called from the killer's
// goroutine, never the victim's.
//
// The notify-and-close half runs on its own goroutine and
// under a short deadline. There is no per-client send
// queue: send() writes straight to the victim's socket, so
// a victim that has stopped reading would otherwise stall
// the killer for the full writeTimeout on each of the two
// writes -- wedging the operator's own serve() loop on the
// IRC path, or the API request on the HTTP path. Any
// client could trigger that deliberately. Disconnect
// therefore returns as soon as the victim is marked closed;
// the socket is closed shortly afterwards regardless of
// whether the notification could be delivered.
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 = "*"
}
// Stop the relay goroutine, which would otherwise keep
// polling a queue belonging to a deleted session.
if c.cancel != nil {
c.cancel()
}
go c.notifyKilledAndClose(nick, host, reason)
}
// notifyKilledAndClose delivers the KILL and ERROR lines to
// a killed victim and then closes its socket. It runs on a
// goroutine owned by neither the killer nor the victim, and
// bounds both writes with killWriteWindow so an unresponsive
// victim cannot hold the socket open indefinitely.
func (c *Conn) notifyKilledAndClose(
nick, host, reason string,
) {
defer c.conn.Close() //nolint:errcheck
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 {
@@ -208,13 +129,7 @@ func (c *Conn) buildCommandMap() map[string]cmdHandler {
"CAP": func(_ context.Context, msg *Message) {
c.handleCAP(msg)
},
"USERHOST": 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.handleKillCmd,
irc.CmdWallops: c.handleWallopsCmd,
"USERHOST": c.handleUserhost,
}
}
@@ -235,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)
@@ -263,7 +180,7 @@ func (c *Conn) serve(ctx context.Context) {
c.handleMessage(ctx, msg)
if c.isClosed() {
if c.closed {
return
}
}
@@ -272,65 +189,24 @@ 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.UnregisterWireConn(sessID, c)
// A KILLed session has already been broadcast and
// deleted by the killer; broadcasting again would
// fan out a QUIT for a session row that is gone.
if !wasKilled {
c.svc.BroadcastQuit(
ctx, sessID, nick, "Connection closed",
)
}
c.svc.BroadcastQuit(
ctx, sessID, nick, "Connection closed",
)
}
c.conn.Close() //nolint:errcheck,gosec
}
// isClosed reports whether the connection has been marked
// for teardown, either by QUIT or by an operator KILL.
func (c *Conn) isClosed() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.closed
}
// currentNick returns the connection's registered nick.
// c.nick is written under c.mu during registration and
// NICK changes, so every read must take the mutex.
func (c *Conn) currentNick() string {
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
// under the given write deadline. Callers that must not be
// held hostage by an unresponsive peer pass a shorter window
// than writeTimeout.
func (c *Conn) sendWithin(
timeout time.Duration,
line string,
) {
c.writeMu.Lock()
defer c.writeMu.Unlock()
_ = c.conn.SetWriteDeadline(
time.Now().Add(timeout),
time.Now().Add(writeTimeout),
)
_, _ = fmt.Fprintf(c.conn, "%s\r\n", line)
@@ -511,10 +387,7 @@ 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
}
@@ -525,10 +398,6 @@ func (c *Conn) completeRegistration(ctx context.Context) {
c.registered = true
c.mu.Unlock()
// Make this connection reachable by session ID so that
// KILL from either transport can disconnect it.
c.svc.RegisterWireConn(sessionID, c)
// If PASS was provided before registration, set the
// session password.
if c.passWord != "" && len(c.passWord) >= minPasswordLen {
@@ -613,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)
}
-128
View File
@@ -1,128 +0,0 @@
package ircserver_test
import (
"bufio"
"log/slog"
"net"
"os"
"strings"
"testing"
"time"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/ircserver"
)
// disconnectBudget is how long Disconnect is allowed to
// take when the victim never reads. It is far below the
// 30s writeTimeout that the old synchronous implementation
// would have burned on each of its two writes.
const disconnectBudget = 2 * time.Second
// TestDisconnectDoesNotBlockOnUnresponsiveVictim proves
// that an operator KILL cannot be stalled by its target.
// The victim's socket is a net.Pipe, so every write blocks
// until the peer reads and the peer here never does. The
// old implementation performed both notification writes on
// the killer's goroutine, which wedged the operator's own
// serve() loop on the IRC path and the API request on the
// HTTP path for as long as the victim cared to stay silent.
func TestDisconnectDoesNotBlockOnUnresponsiveVictim(
t *testing.T,
) {
t.Parallel()
serverSide, clientSide := net.Pipe()
t.Cleanup(func() {
_ = clientSide.Close()
})
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
))
cfg := &config.Config{ //nolint:exhaustruct
ServerName: "test.irc",
}
victim := ircserver.NewTestConn(
log, cfg, serverSide, "victim",
)
returned := make(chan struct{})
go func() {
victim.Disconnect("killed by oper")
close(returned)
}()
select {
case <-returned:
case <-time.After(disconnectBudget):
t.Fatal(
"Disconnect blocked on the victim's socket; " +
"the killer must not be held hostage",
)
}
}
// TestDisconnectNotifiesAndClosesVictim is the other half
// of the contract: moving the notification off the killer's
// goroutine must not lose it. A victim that does read gets
// both the KILL and the ERROR line, and then its socket is
// closed so its read loop unblocks.
func TestDisconnectNotifiesAndClosesVictim(t *testing.T) {
t.Parallel()
serverSide, clientSide := net.Pipe()
t.Cleanup(func() {
_ = clientSide.Close()
})
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
))
cfg := &config.Config{ //nolint:exhaustruct
ServerName: "test.irc",
}
victim := ircserver.NewTestConn(
log, cfg, serverSide, "victim",
)
lines := make(chan []string, 1)
go func() {
var got []string
scanner := bufio.NewScanner(clientSide)
for scanner.Scan() {
got = append(got, scanner.Text())
}
lines <- got
}()
victim.Disconnect("killed by oper")
var got []string
select {
case got = <-lines:
case <-time.After(5 * time.Second):
t.Fatal("victim socket was never closed")
}
joined := strings.Join(got, "\n")
if !strings.Contains(joined, "KILL victim") {
t.Errorf("missing KILL line, got: %q", joined)
}
if !strings.Contains(joined, "ERROR :Closing Link:") {
t.Errorf("missing ERROR line, got: %q", joined)
}
}
+1 -27
View File
@@ -4,12 +4,10 @@ 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"
)
@@ -21,14 +19,8 @@ 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, globs, log,
database, brk, cfg, log,
)
return &Server{ //nolint:exhaustruct
@@ -55,21 +47,3 @@ func (s *Server) Stop() {
func (s *Server) Listener() net.Listener {
return s.listener
}
// NewTestConn wraps an already-established net.Conn in a
// Conn so tests can drive connection-level behaviour such
// as Disconnect without standing up a whole server.
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
}
+4 -407
View File
@@ -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 ") {
@@ -760,336 +762,6 @@ 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")
// Query single nick.
alice.send("USERHOST bob")
aliceReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 302 ")
})
assertContains(
t, aliceReply, " 302 ",
"RPL_USERHOST",
)
assertContains(
t, aliceReply, "bob",
"USERHOST contains queried nick",
)
// Query multiple nicks.
bob.send("USERHOST alice bob")
bobReply := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 302 ")
})
assertContains(
t, bobReply, " 302 ",
"RPL_USERHOST multi-nick",
)
assertContains(
t, bobReply, "alice",
"USERHOST multi contains alice",
)
assertContains(
t, bobReply, "bob",
"USERHOST multi contains bob",
)
}
// TestIntegrationVersion verifies the VERSION command
// 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, "test.irc",
"TIME reply includes server name",
)
}
// TestIntegrationKill verifies the KILL command: oper can
// kill a user, non-oper cannot, and — most importantly —
// that the victim is actually disconnected rather than
// merely announced as having quit.
//
//nolint:funlen // one KILL scenario asserted end to end
func TestIntegrationKill(t *testing.T) {
t.Parallel()
env := newTestEnvWithOper(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
// Both join a channel so KILL's QUIT is visible.
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")
})
// Non-oper KILL should fail.
alice.send("KILL bob :nope")
aliceKillFail := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 481 ")
})
assertContains(
t, aliceKillFail, " 481 ",
"ERR_NOPRIVILEGES for non-oper KILL",
)
// alice becomes oper.
alice.send("OPER testoper testpass")
alice.readUntil(func(l string) bool {
return strings.Contains(l, " 381 ")
})
// Oper KILL should succeed.
alice.send("KILL bob :bad behavior")
// The victim must be told why and then disconnected.
// Reading to EOF is the assertion that matters: a KILL
// that only broadcasts a QUIT leaves bob holding a
// socket that looks alive but delivers nothing.
bobLines := bob.readUntilClosed()
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",
)
// KILL nonexistent nick.
alice.send("KILL nobody123 :gone")
aliceNoSuch := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 401 ")
})
assertContains(
t, aliceNoSuch, " 401 ",
"ERR_NOSUCHNICK for KILL missing target",
)
}
// TestIntegrationWallops verifies the WALLOPS command:
// 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 becomes oper.
alice.send("OPER testoper testpass")
alice.readUntil(func(l string) bool {
return strings.Contains(l, " 381 ")
})
// bob sets +w to receive wallops.
bob.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",
)
}
// TestIntegrationModeSecret tests +s (secret) channel
// mode — verifies that +s can be set and the mode is
// reflected in MODE queries.
@@ -1163,6 +835,7 @@ func TestIntegrationModeModerated(t *testing.T) {
})
foundModErr := false
for _, line := range bobLines {
if strings.Contains(line, " 404 ") ||
strings.Contains(line, " 482 ") {
@@ -1189,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")
})
@@ -1241,80 +915,3 @@ func TestIntegrationThirdClientObserver(t *testing.T) {
"carol receives trio message",
)
}
// assertNoEmptyParam fails if any line contains a doubled
// space, which is what FormatMessage emits for an empty
// non-trailing parameter. A numeric whose server-name
// parameter came out empty is malformed on the wire even
// though it still contains the numeric code, so the other
// assertions in this file would not catch it.
func assertNoEmptyParam(
t *testing.T,
lines []string,
context string,
) {
t.Helper()
for _, line := range lines {
if strings.Contains(line, " ") {
t.Errorf(
"%s: empty parameter in wire line: %q",
context, line,
)
}
}
}
// TestIntegrationDefaultServerNameFallback runs the wire
// server with SERVER_NAME unset, which is the shipped
// default from config.go, and verifies that VERSION, ADMIN
// and TIME fall back to "neoirc" exactly as the HTTP path
// does instead of emitting an empty server-name parameter.
//
// Every other wire test hardcodes ServerName: "test.irc",
// so none of them exercise the default configuration.
func TestIntegrationDefaultServerNameFallback(t *testing.T) {
t.Parallel()
env := newTestEnvWithServerName(t, "")
alice := env.dial(t)
alice.register("alice")
alice.send("VERSION")
versionReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 351 ")
})
assertNoEmptyParam(t, versionReply, "VERSION")
assertContains(
t, versionReply, "neoirc",
"VERSION falls back to default server name",
)
alice.send("ADMIN")
adminReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 259 ")
})
assertNoEmptyParam(t, adminReply, "ADMIN")
assertContains(
t, adminReply, " 256 alice neoirc ",
"RPL_ADMINME names the default server",
)
assertContains(
t, adminReply, "admin@neoirc",
"RPL_ADMINEMAIL is a well-formed address",
)
alice.send("TIME")
timeReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 391 ")
})
assertNoEmptyParam(t, timeReply, "TIME")
assertContains(
t, timeReply, " 391 alice neoirc ",
"RPL_TIME names the default server",
)
}
+40 -28
View File
@@ -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",
+39 -21
View File
@@ -120,8 +120,6 @@ 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:
@@ -130,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)
}
}
}
@@ -160,8 +162,12 @@ func (c *Conn) deliverNumeric(
_ = json.Unmarshal(msg.Params, &params)
}
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 != "" {
@@ -179,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
}
@@ -194,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
}
@@ -208,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
}
@@ -229,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
}
@@ -243,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
}
@@ -304,19 +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()
// 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,
))
c.sendFromServer("NOTICE", nick, text)
}
// deliverMode sends a MODE change notification.
+6 -4
View File
@@ -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
}
+8 -149
View File
@@ -38,20 +38,6 @@ type testEnv struct {
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
return newTestEnvWithServerName(t, "test.irc")
}
// newTestEnvWithServerName creates a test environment with
// an explicit SERVER_NAME. Passing "" exercises the shipped
// default from config.go, under which the server must fall
// back to "neoirc" rather than emitting an empty
// server-name parameter.
func newTestEnvWithServerName(
t *testing.T,
serverName string,
) *testEnv {
t.Helper()
dsn := fmt.Sprintf(
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
t.Name(),
@@ -81,92 +67,13 @@ func newTestEnvWithServerName(
brk := broker.New()
cfg := &config.Config{ //nolint:exhaustruct
ServerName: serverName,
ServerName: "test.irc",
MOTD: "Welcome to test IRC",
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
var listenConfig net.ListenConfig
addr := listener.Addr().String()
err = listener.Close()
if err != nil {
t.Fatalf("close listener: %v", err)
}
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
))
srv := ircserver.NewTestServer(log, cfg, database, brk)
err = srv.Start(addr)
if err != nil {
t.Fatalf("start irc server: %v", err)
}
t.Cleanup(func() {
srv.Stop()
err := conn.Close()
if err != nil {
t.Logf("close db: %v", err)
}
})
return &testEnv{
database: database,
brk: brk,
cfg: cfg,
srv: srv,
}
}
// newTestEnvWithOper creates a test environment with oper
// credentials configured.
func newTestEnvWithOper(t *testing.T) *testEnv {
t.Helper()
dsn := fmt.Sprintf(
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
t.Name(),
)
conn, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("open db: %v", err)
}
conn.SetMaxOpenConns(1)
_, err = conn.ExecContext(
t.Context(), "PRAGMA foreign_keys = ON",
)
if err != nil {
t.Fatalf("pragma: %v", err)
}
database := db.NewTestDatabaseFromConn(conn)
err = database.RunMigrations(t.Context())
if err != nil {
t.Fatalf("migrate: %v", err)
}
brk := broker.New()
cfg := &config.Config{ //nolint:exhaustruct
ServerName: "test.irc",
MOTD: "Welcome to test IRC",
OperName: "testoper",
OperPassword: "testpass",
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
@@ -211,10 +118,12 @@ func newTestEnvWithOper(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)
@@ -305,36 +214,6 @@ func (tc *testClient) register(nick string) []string {
})
}
// readUntilClosed reads until the peer closes the
// connection, returning the lines received first. It
// fails the test if the connection is still open when the
// read deadline expires, which is what a KILL that never
// terminates the victim's socket looks like.
func (tc *testClient) readUntilClosed() []string {
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(
"expected EOF on victim socket, got %v "+
"(lines: %v)",
err, lines,
)
}
return lines
}
// assertContains checks that at least one line matches the
// given substring.
func assertContains(
@@ -353,27 +232,6 @@ 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) {
@@ -469,6 +327,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")
}
+13 -9
View File
@@ -45,6 +45,7 @@ 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
@@ -70,17 +71,16 @@ func New(
lifecycle.Append(fx.Hook{
OnStart: func(_ context.Context) error {
srv.startupTime = time.Now()
// Configure, enable Sentry, and build the router
// synchronously so that srv.router is fully initialized
// before OnStart returns. Any HTTP traffic (including
// httptest harnesses that wrap srv as a handler) is
// therefore guaranteed to see an initialized router,
// eliminating the previous race between SetupRoutes
// and ServeHTTP.
// 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
// The start hook's context ends when the hook
// returns; serving must outlive it.
go srv.serve() //nolint:contextcheck,gosec // G118
return nil
},
@@ -129,7 +129,7 @@ func (srv *Server) enableSentry() {
srv.sentryEnabled = true
}
func (srv *Server) serve() {
func (srv *Server) serve() int {
srv.ctx, srv.cancelFunc = context.WithCancel(
context.Background(),
)
@@ -154,6 +154,8 @@ func (srv *Server) serve() {
<-srv.ctx.Done()
srv.cleanShutdown()
return srv.exitCode
}
func (srv *Server) cleanupForExit() {
@@ -161,6 +163,8 @@ func (srv *Server) cleanupForExit() {
}
func (srv *Server) cleanShutdown() {
srv.exitCode = 0
ctxShutdown, shutdownCancel := context.WithTimeout(
context.Background(), shutdownTimeout,
)
+43 -350
View File
@@ -10,18 +10,21 @@ import (
"log/slog"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/pkg/irc"
)
// 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
@@ -30,41 +33,23 @@ type Params struct {
Config *config.Config
Database *db.Database
Broker *broker.Broker
Globals *globals.Globals
}
// WireConn is a live client connection that a transport
// registers with the service so that commands such as KILL
// can reach it. The IRC wire server registers one per
// registered connection; the HTTP transport has no
// long-lived socket and registers nothing.
type WireConn interface {
// Disconnect terminates the connection, telling the
// client why before closing the socket.
Disconnect(reason string)
}
// Service provides shared business logic for IRC commands.
type Service struct {
db *db.Database
broker *broker.Broker
config *config.Config
globals *globals.Globals
log *slog.Logger
wireMu sync.Mutex
wireConns map[int64]WireConn
db *db.Database
broker *broker.Broker
config *config.Config
log *slog.Logger
}
// New creates a new Service.
func New(params Params) *Service {
return &Service{ //nolint:exhaustruct // mutex zero value
db: params.Database,
broker: params.Broker,
config: params.Config,
globals: params.Globals,
log: params.Logger.Get(),
wireConns: make(map[int64]WireConn),
return &Service{
db: params.Database,
broker: params.Broker,
config: params.Config,
log: params.Logger.Get(),
}
}
@@ -74,105 +59,13 @@ func NewTestService(
database *db.Database,
brk *broker.Broker,
cfg *config.Config,
globs *globals.Globals,
log *slog.Logger,
) *Service {
return &Service{ //nolint:exhaustruct // mutex zero value
db: database,
broker: brk,
config: cfg,
globals: globs,
log: log,
wireConns: make(map[int64]WireConn),
}
}
// ServerVersion returns the canonical server version string
// used by every transport, e.g. "neoirc-1.2.3". Both the
// IRC wire protocol and the HTTP API must report the same
// string, so this is the only place it is built.
func (s *Service) ServerVersion() string {
name := "neoirc"
ver := "dev"
if s.globals != nil {
if s.globals.Appname != "" {
name = s.globals.Appname
}
if s.globals.Version != "" {
ver = s.globals.Version
}
}
return name + "-" + ver
}
// InfoLines returns the RPL_INFO body. Both transports
// send exactly these lines so that INFO does not diverge
// between the wire protocol and the HTTP API.
func (s *Service) InfoLines() []string {
started := "unknown"
if s.globals != nil && !s.globals.StartTime.IsZero() {
started = s.globals.StartTime.Format(time.RFC1123)
}
return []string{
"neoirc — IRC semantics over HTTP",
"Version: " + s.ServerVersion(),
"Written in Go",
"Started: " + started,
}
}
// RegisterWireConn associates a live wire connection with
// its session ID so that KillSession can reach it.
func (s *Service) RegisterWireConn(
sessionID int64,
conn WireConn,
) {
s.wireMu.Lock()
defer s.wireMu.Unlock()
s.wireConns[sessionID] = conn
}
// UnregisterWireConn removes the association created by
// RegisterWireConn. It is a no-op if the session has
// already been rebound to a different connection.
func (s *Service) UnregisterWireConn(
sessionID int64,
conn WireConn,
) {
s.wireMu.Lock()
defer s.wireMu.Unlock()
if s.wireConns[sessionID] == conn {
delete(s.wireConns, sessionID)
}
}
// KillSession forcibly removes a user from the server: the
// victim's channel peers are told via QUIT, the victim's
// session is deleted, and any live wire connection it holds
// is disconnected. Both the IRC KILL command and the HTTP
// KILL endpoint route through here so the two transports
// cannot diverge.
func (s *Service) KillSession(
ctx context.Context,
sessionID int64,
nick, reason string,
) {
s.BroadcastQuit(ctx, sessionID, nick, reason)
// A session with no registered wire connection (an
// HTTP-only client) has nothing left to disconnect.
s.wireMu.Lock()
conn := s.wireConns[sessionID]
s.wireMu.Unlock()
if conn != nil {
conn.Disconnect(reason)
return &Service{
db: database,
broker: brk,
config: cfg,
log: log,
}
}
@@ -255,7 +148,7 @@ func (s *Service) SendChannelMessage(
return 0, "", &IRCError{
irc.ErrNoSuchChannel,
[]string{channel},
"No such channel",
msgNoSuchChannel,
}
}
@@ -369,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
}
}
@@ -419,7 +313,7 @@ func (s *Service) PartChannel(
return &IRCError{
irc.ErrNoSuchChannel,
[]string{channel},
"No such channel",
msgNoSuchChannel,
}
}
@@ -461,7 +355,7 @@ func (s *Service) SetTopic(
return &IRCError{
irc.ErrNoSuchChannel,
[]string{channel},
"No such channel",
msgNoSuchChannel,
}
}
@@ -485,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)
}
@@ -522,7 +415,7 @@ func (s *Service) KickUser(
return &IRCError{
irc.ErrNoSuchChannel,
[]string{channel},
"No such channel",
msgNoSuchChannel,
}
}
@@ -533,7 +426,7 @@ func (s *Service) KickUser(
return &IRCError{
irc.ErrChanOpPrivsNeeded,
[]string{channel},
"You're not channel operator",
msgNotChannelOp,
}
}
@@ -722,7 +615,7 @@ func (s *Service) ValidateChannelOp(
return 0, &IRCError{
irc.ErrNoSuchChannel,
[]string{channel},
"No such channel",
msgNoSuchChannel,
}
}
@@ -733,7 +626,7 @@ func (s *Service) ValidateChannelOp(
return 0, &IRCError{
irc.ErrChanOpPrivsNeeded,
[]string{channel},
"You're not channel operator",
msgNotChannelOp,
}
}
@@ -795,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,
)
@@ -904,201 +792,6 @@ func (s *Service) QueryChannelMode(
return modes + modeParams
}
// QueryUserMode returns the current user mode string for
// the given session (e.g. "+ow", "+w", "+"). A database
// failure is returned rather than being reported as an
// unset flag: an unreadable mode is not the same as an
// absent one, and reporting "+" for either would tell the
// user they are de-opered when the truth is unknown.
func (s *Service) QueryUserMode(
ctx context.Context,
sessionID int64,
) (string, error) {
modes := "+"
isOper, err := s.db.IsSessionOper(ctx, sessionID)
if err != nil {
return "", fmt.Errorf(
"query oper flag: %w", err,
)
}
if isOper {
modes += "o"
}
isWallops, err := s.db.IsSessionWallops(
ctx, sessionID,
)
if err != nil {
return "", fmt.Errorf(
"query wallops flag: %w", err,
)
}
if isWallops {
modes += "w"
}
return modes, nil
}
// userModeOp is a single parsed user-mode change collected
// by parseUserModeString before any DB writes happen.
type userModeOp struct {
char rune
adding bool
}
// ApplyUserMode parses an IRC user-mode string and applies
// the resulting changes atomically. It supports multiple
// sign transitions (e.g. "+w-o", "-w+o", "+o-w+w") and
// rejects malformed input (empty string, no leading sign,
// bare sign with no mode letters, unknown mode letters,
// +o which must be set via OPER) with an IRCError. On
// failure, no persistent change is made: parsing happens
// before any write, and the writes themselves run inside a
// single database transaction that is rolled back whole if
// any statement fails. On success, the resulting mode
// string is returned.
func (s *Service) ApplyUserMode(
ctx context.Context,
sessionID int64,
modeStr string,
) (string, error) {
ops, err := parseUserModeString(modeStr)
if err != nil {
return "", err
}
wallops, oper, err := collapseUserModeOps(ops)
if err != nil {
return "", err
}
if err := s.db.SetSessionUserModes(
ctx, sessionID, wallops, oper,
); err != nil {
s.log.Error(
"apply user modes failed", "error", err,
)
return "", fmt.Errorf("apply user modes: %w", err)
}
return s.QueryUserMode(ctx, sessionID)
}
// parseUserModeString validates and parses a user-mode
// string into a list of operations. The string must begin
// with '+' or '-'; subsequent '+' / '-' characters flip the
// active sign, and letters between them are applied with
// the current sign. Every letter must be a recognized user
// mode for this server, and '+o' is never allowed via MODE
// (use OPER to become operator). If any character is
// invalid, no operations are returned and an IRCError with
// ERR_UMODEUNKNOWNFLAG (501) is returned.
func parseUserModeString(
modeStr string,
) ([]userModeOp, error) {
unknownFlag := &IRCError{
Code: irc.ErrUmodeUnknownFlag,
Params: nil,
Message: "Unknown MODE flag",
}
if modeStr == "" {
return nil, unknownFlag
}
first := modeStr[0]
if first != '+' && first != '-' {
return nil, unknownFlag
}
ops := make([]userModeOp, 0, len(modeStr)-1)
adding := true
for _, modeChar := range modeStr {
switch modeChar {
case '+':
adding = true
case '-':
adding = false
default:
if !isKnownUserModeChar(modeChar) {
return nil, unknownFlag
}
if modeChar == 'o' && adding {
return nil, unknownFlag
}
ops = append(ops, userModeOp{
char: modeChar, adding: adding,
})
}
}
if len(ops) == 0 {
return nil, unknownFlag
}
return ops, nil
}
// isKnownUserModeChar reports whether the character is a
// recognized user mode letter.
func isKnownUserModeChar(modeChar rune) bool {
switch modeChar {
case 'w', 'o':
return true
default:
return false
}
}
// collapseUserModeOps reduces an already-parsed operation
// list to the final desired value of each user mode flag.
// A nil result for a flag means the mode string never
// mentioned it, so it must be left untouched. Later
// operations win over earlier ones for the same letter
// (e.g. "+w-w" ends with wallops off), which matches the
// left-to-right semantics of applying each op in turn.
// parseUserModeString must have validated every character
// and sign before this runs; the default branch here is
// defence-in-depth only.
func collapseUserModeOps(
ops []userModeOp,
) (*bool, *bool, error) {
unknownFlag := &IRCError{
Code: irc.ErrUmodeUnknownFlag,
Params: nil,
Message: "Unknown MODE flag",
}
var wallops, oper *bool
for _, modeOp := range ops {
switch modeOp.char {
case 'w':
val := modeOp.adding
wallops = &val
case 'o':
if modeOp.adding {
return nil, nil, unknownFlag
}
val := false
oper = &val
default:
return nil, nil, unknownFlag
}
}
return wallops, oper, nil
}
// broadcastNickChange notifies channel peers of a nick
// change.
func (s *Service) broadcastNickChange(
+3 -452
View File
@@ -11,7 +11,6 @@ import (
"fmt"
"os"
"testing"
"time"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
@@ -56,10 +55,9 @@ func newTestEnv(t *testing.T) *testEnv {
app := fxtest.New(t,
fx.Provide(
func() *globals.Globals {
return &globals.Globals{
Appname: "neoirc-test",
Version: "test",
StartTime: time.Now(),
return &globals.Globals{ //nolint:exhaustruct
Appname: "neoirc-test",
Version: "test",
}
},
logger.New,
@@ -365,450 +363,3 @@ 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")
// Fresh session has no modes.
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)
}
// Set wallops.
_ = env.db.SetSessionWallops(ctx, sid, 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)
}
// Set oper.
_ = env.db.SetSessionOper(ctx, sid, 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)
}
}
// applyUserModeCaseState is the subset of session user-mode
// state the rigorous TestApplyUserMode suite asserts on. It
// mirrors the columns (oper, wallops) that the parser is
// permitted to mutate.
type applyUserModeCaseState struct {
oper bool
wallops bool
}
// applyUserModeCase describes one rigor-suite case for
// Service.ApplyUserMode: the pre-call DB state, the mode
// string input, and the expected post-call observable state
// (mode string on success, IRC error code on rejection, and
// persisted session state either way).
type applyUserModeCase struct {
name string
initialState applyUserModeCaseState
modeStr string
wantModes string
wantErr bool
wantErrCode irc.IRCMessageType
wantState applyUserModeCaseState
}
// applyUserModeCases returns every case listed in sneak's
// review of PR #96 plus a few adjacent ones. Split across
// helpers by category so each stays under funlen.
func applyUserModeCases() []applyUserModeCase {
cases := applyUserModeHappyPathCases()
cases = append(cases, applyUserModeSignTransitionCases()...)
cases = append(cases, applyUserModeMalformedCases()...)
cases = append(cases, applyUserModeUnknownLetterCases()...)
return cases
}
// applyUserModeHappyPathCases covers valid single-char and
// multi-char-without-sign-transition mode operations.
func applyUserModeHappyPathCases() []applyUserModeCase {
return []applyUserModeCase{
{
name: "+w from empty",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+w",
wantModes: "+w",
wantErr: false,
wantErrCode: 0,
wantState: applyUserModeCaseState{oper: false, wallops: true},
},
{
name: "-w from +w",
initialState: applyUserModeCaseState{oper: false, wallops: true},
modeStr: "-w",
wantModes: "+",
wantErr: false,
wantErrCode: 0,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "-o from +o",
initialState: applyUserModeCaseState{oper: true, wallops: false},
modeStr: "-o",
wantModes: "+",
wantErr: false,
wantErrCode: 0,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "-wo from +ow",
initialState: applyUserModeCaseState{oper: true, wallops: true},
modeStr: "-wo",
wantModes: "+",
wantErr: false,
wantErrCode: 0,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
}
}
// applyUserModeSignTransitionCases covers multi-char mode
// strings where '+' and '-' flip partway through. +o is
// never legal via MODE, so strings containing it must be
// rejected atomically.
func applyUserModeSignTransitionCases() []applyUserModeCase {
return []applyUserModeCase{
{
name: "+w-o from +o",
initialState: applyUserModeCaseState{oper: true, wallops: false},
modeStr: "+w-o",
wantModes: "+w",
wantErr: false,
wantErrCode: 0,
wantState: applyUserModeCaseState{oper: false, wallops: true},
},
{
// +o is rejected before any op applies; wallops
// stays set.
name: "-w+o always rejects +o",
initialState: applyUserModeCaseState{oper: true, wallops: true},
modeStr: "-w+o",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: true, wallops: true},
},
{
// Wallops must NOT be cleared; oper must NOT be
// cleared. Rejection is fully atomic.
name: "+o-w+w rejects because of +o",
initialState: applyUserModeCaseState{oper: true, wallops: true},
modeStr: "+o-w+w",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: true, wallops: true},
},
}
}
// applyUserModeMalformedCases covers inputs that lack a
// leading '+' or '-' and inputs that consist of bare signs
// without mode letters. All must be rejected with no side
// effects.
func applyUserModeMalformedCases() []applyUserModeCase {
return []applyUserModeCase{
{
name: "w no prefix rejects",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "w",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
// Prove wallops is NOT cleared — the whole point
// of sneak's review.
name: "xw no prefix rejects (would have been" +
" silently -w before)",
initialState: applyUserModeCaseState{oper: false, wallops: true},
modeStr: "xw",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: true},
},
{
name: "empty string rejects",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "bare + rejects",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "bare - rejects",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "-",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "+-+ rejects (no mode letters)",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+-+",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
}
}
// applyUserModeUnknownLetterCases covers well-formed prefix
// strings that contain unknown mode letters. Rejection must
// be atomic: any valid letters before the invalid one must
// not persist.
func applyUserModeUnknownLetterCases() []applyUserModeCase {
return []applyUserModeCase{
{
name: "-x+y rejects unknown -x",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "-x+y",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "+y-x rejects unknown +y",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+y-x",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "+z unknown mode rejects",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+z",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
// Wallops must NOT be set.
name: "+wz rejects whole thing; +w side effect" +
" must NOT persist",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+wz",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
{
name: "+wo rejects whole thing; +w side effect" +
" must NOT persist",
initialState: applyUserModeCaseState{oper: false, wallops: false},
modeStr: "+wo",
wantModes: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: applyUserModeCaseState{oper: false, wallops: false},
},
}
}
// TestApplyUserMode is the rigorous table-driven suite for
// the shared user-mode parser. It covers every case listed
// in sneak's review of PR #96 plus a few adjacent ones.
// Each case asserts the resulting mode string AND the
// persisted session state, to prove that rejected input
// leaves no side effects.
func TestApplyUserMode(t *testing.T) {
for _, testCase := range applyUserModeCases() {
t.Run(testCase.name, func(t *testing.T) {
runApplyUserModeCase(t, testCase)
})
}
}
// runApplyUserModeCase executes one applyUserModeCase: seed
// the session state, invoke ApplyUserMode, and verify both
// the returned value and the post-call persisted state.
func runApplyUserModeCase(
t *testing.T, testCase applyUserModeCase,
) {
t.Helper()
env := newTestEnv(t)
ctx := t.Context()
sid := createSession(ctx, t, env.db, "alice")
seedApplyUserModeState(ctx, t, env.db, sid, testCase.initialState)
result, err := env.svc.ApplyUserMode(
ctx, sid, testCase.modeStr,
)
verifyApplyUserModeOutcome(t, testCase, result, err)
verifyApplyUserModeState(ctx, t, env.db, sid, testCase.wantState)
}
// seedApplyUserModeState installs the pre-call session
// state described by initialState.
func seedApplyUserModeState(
ctx context.Context,
t *testing.T,
database *db.Database,
sid int64,
initialState applyUserModeCaseState,
) {
t.Helper()
if initialState.oper {
if err := database.SetSessionOper(
ctx, sid, true,
); err != nil {
t.Fatalf("init oper: %v", err)
}
}
if initialState.wallops {
if err := database.SetSessionWallops(
ctx, sid, true,
); err != nil {
t.Fatalf("init wallops: %v", err)
}
}
}
// verifyApplyUserModeOutcome asserts the direct return
// value of ApplyUserMode. It dispatches to the error- or
// success-specific verifier based on wantErr.
func verifyApplyUserModeOutcome(
t *testing.T,
testCase applyUserModeCase,
result string,
err error,
) {
t.Helper()
if testCase.wantErr {
verifyApplyUserModeError(t, testCase, err)
return
}
verifyApplyUserModeSuccess(t, testCase, result, err)
}
// verifyApplyUserModeError checks that err is a
// *service.IRCError whose code matches wantErrCode.
func verifyApplyUserModeError(
t *testing.T, testCase applyUserModeCase, err error,
) {
t.Helper()
var ircErr *service.IRCError
if !errors.As(err, &ircErr) {
t.Fatalf("expected IRCError, got %v", err)
}
if ircErr.Code != testCase.wantErrCode {
t.Errorf(
"code: want %d got %d",
testCase.wantErrCode, ircErr.Code,
)
}
}
// verifyApplyUserModeSuccess checks that err is nil and the
// returned mode string matches wantModes.
func verifyApplyUserModeSuccess(
t *testing.T,
testCase applyUserModeCase,
result string,
err error,
) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != testCase.wantModes {
t.Errorf(
"modes: want %q got %q",
testCase.wantModes, result,
)
}
}
// verifyApplyUserModeState asserts the post-call persisted
// session state. This is the atomicity guarantee sneak
// demanded: whether the call succeeded or was rejected, the
// DB must match wantState exactly.
func verifyApplyUserModeState(
ctx context.Context,
t *testing.T,
database *db.Database,
sid int64,
wantState applyUserModeCaseState,
) {
t.Helper()
gotOper, err := database.IsSessionOper(ctx, sid)
if err != nil {
t.Fatalf("read oper: %v", err)
}
gotWallops, err := database.IsSessionWallops(ctx, sid)
if err != nil {
t.Fatalf("read wallops: %v", err)
}
if gotOper != wantState.oper {
t.Errorf(
"oper: want %v got %v",
wantState.oper, gotOper,
)
}
if gotWallops != wantState.wallops {
t.Errorf(
"wallops: want %v got %v",
wantState.wallops, gotWallops,
)
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"private": true,
"devDependencies": {
"prettier": "3.8.1"
}
}
+22 -29
View File
@@ -2,33 +2,26 @@ package irc
// IRC command names (RFC 1459 / RFC 2812).
const (
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"
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"
)
+2 -1
View File
@@ -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",
+46 -45
View File
@@ -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)
+143
View File
@@ -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 <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
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 <file> <expected-hash>
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 "$@"
Executable
+16
View File
@@ -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 "$@"
Executable
+28
View File
@@ -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 "$@"
Executable
+24
View File
@@ -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 "$@"
Executable
+35
View File
@@ -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 "$@"
+40
View File
@@ -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 "$@"
+16
View File
@@ -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 "$@"
Executable
+23
View File
@@ -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 "$@"
+23
View File
@@ -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 "$@"
+12
View File
@@ -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 "$@"
Executable
+13
View File
@@ -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 "$@"
Executable
+19
View File
@@ -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 "$@"
+8
View File
@@ -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==