Compare commits
47 Commits
fix/29-spl
...
feature/87
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edbbdc9ca5 | ||
|
|
dbdef00e91 | ||
|
|
209b0ff364 | ||
|
|
cfff726054 | ||
|
|
534d10d719 | ||
|
|
86813b506b | ||
|
|
c20ad88dfe | ||
|
|
f24e33a310 | ||
|
|
93611dad67 | ||
|
|
abe0cc2c30 | ||
|
|
327ff37059 | ||
|
|
17479c4f44 | ||
|
|
9c4ec966fb | ||
| f829f9e3da | |||
| 02279b2654 | |||
| 0250f14fea | |||
| 24362966e0 | |||
| 9a79d92c0d | |||
| e62962d192 | |||
| 4b445e6383 | |||
| 08f57bc105 | |||
| 5f3c0633f6 | |||
| db3d23c224 | |||
| bf4d63bc4d | |||
| efbd8fe9ff | |||
| e36bd99ef6 | |||
| e9d794764b | |||
| 052674b4ee | |||
| cab5784913 | |||
| 75cecd9803 | |||
| f2e7a6ec85 | |||
| 27df999942 | |||
| b19c8b5759 | |||
| 67446b36a1 | |||
| b1fd2f1b96 | |||
| c07f94a432 | |||
| a98e0ca349 | |||
| f287fdf6d1 | |||
| 687c958bd1 | |||
| 946f208ac2 | |||
| 47fb089969 | |||
| f8f0b6afbb | |||
| 8e3166969e | |||
| c0e344d6fc | |||
| 2da7f11484 | |||
| 6e7bf028c1 | |||
| 2761ee156a |
@@ -1,8 +1,8 @@
|
|||||||
.git
|
.git
|
||||||
*.md
|
*.md
|
||||||
!README.md
|
!README.md
|
||||||
chatd
|
neoircd
|
||||||
chat-cli
|
neoirc-cli
|
||||||
data.db
|
data.db
|
||||||
data.db-wal
|
data.db-wal
|
||||||
data.db-shm
|
data.db-shm
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -21,7 +21,8 @@ node_modules/
|
|||||||
*.key
|
*.key
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
/chatd
|
web/dist/
|
||||||
|
/neoircd
|
||||||
/bin/
|
/bin/
|
||||||
*.exe
|
*.exe
|
||||||
*.dll
|
*.dll
|
||||||
@@ -34,5 +35,5 @@ vendor/
|
|||||||
# Project
|
# Project
|
||||||
data.db
|
data.db
|
||||||
debug.log
|
debug.log
|
||||||
/chat-cli
|
/neoirc-cli
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
1. **Format**: `gofmt -s -w .` and `goimports -w .`
|
1. **Format**: `gofmt -s -w .` and `goimports -w .`
|
||||||
2. **Lint**: `golangci-lint run --config .golangci.yml ./...` — zero issues
|
2. **Lint**: `golangci-lint run --config .golangci.yml ./...` — zero issues
|
||||||
3. **Test**: `go test -race ./...` — all passing
|
3. **Test**: `go test -race ./...` — all passing
|
||||||
4. **Build**: `go build ./cmd/chatd` — compiles clean
|
4. **Build**: `go build ./cmd/neoircd` — compiles clean
|
||||||
|
|
||||||
No commit lands on main with lint errors, test failures, or formatting issues.
|
No commit lands on main with lint errors, test failures, or formatting issues.
|
||||||
|
|
||||||
|
|||||||
49
Dockerfile
49
Dockerfile
@@ -1,32 +1,59 @@
|
|||||||
|
# Web build stage — compile SPA from source
|
||||||
|
# node:22-alpine, 2026-03-09
|
||||||
|
FROM node@sha256:8094c002d08262dba12645a3b4a15cd6cd627d30bc782f53229a2ec13ee22a00 AS web-builder
|
||||||
|
WORKDIR /web
|
||||||
|
COPY web/package.json web/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
|
||||||
|
# Build stage
|
||||||
# golang:1.24-alpine, 2026-02-26
|
# golang:1.24-alpine, 2026-02-26
|
||||||
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
|
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
RUN apk add --no-cache git build-base make
|
RUN apk add --no-cache git build-base make
|
||||||
|
|
||||||
# golangci-lint v2.1.6 (eabc2638a66d), 2026-02-26
|
# Force BuildKit to run the lint stage before proceeding
|
||||||
RUN CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@eabc2638a66daf5bb6c6fb052a32fa3ef7b6600d
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
COPY --from=web-builder /web/dist/ web/dist/
|
||||||
|
|
||||||
# Run all checks — build fails if branch is not green
|
RUN make test
|
||||||
RUN make check
|
|
||||||
|
|
||||||
# Build static binaries (no cgo needed at runtime — modernc.org/sqlite is pure Go)
|
# Build static binaries (no cgo needed at runtime — modernc.org/sqlite is pure Go)
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /chatd ./cmd/chatd/
|
RUN 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 /chat-cli ./cmd/chat-cli/
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /neoirc-cli ./cmd/neoirc-cli/
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
# alpine:3.21, 2026-02-26
|
# alpine:3.21, 2026-02-26
|
||||||
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
||||||
RUN apk add --no-cache ca-certificates \
|
RUN apk add --no-cache ca-certificates \
|
||||||
&& addgroup -S chat && adduser -S chat -G chat
|
&& addgroup -S neoirc && adduser -S neoirc -G neoirc \
|
||||||
COPY --from=builder /chatd /usr/local/bin/chatd
|
&& mkdir -p /var/lib/neoirc \
|
||||||
|
&& chown neoirc:neoirc /var/lib/neoirc
|
||||||
|
COPY --from=builder /neoircd /usr/local/bin/neoircd
|
||||||
|
|
||||||
USER chat
|
USER neoirc
|
||||||
EXPOSE 8080
|
EXPOSE 8080 6667
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD wget -qO- http://localhost:8080/.well-known/healthcheck.json || exit 1
|
CMD wget -qO- http://localhost:8080/.well-known/healthcheck.json || exit 1
|
||||||
ENTRYPOINT ["chatd"]
|
ENTRYPOINT ["neoircd"]
|
||||||
|
|||||||
31
Makefile
31
Makefile
@@ -1,16 +1,27 @@
|
|||||||
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
|
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks ensure-web-dist
|
||||||
|
|
||||||
BINARY := chatd
|
BINARY := neoircd
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
BUILDARCH := $(shell go env GOARCH)
|
BUILDARCH := $(shell go env GOARCH)
|
||||||
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
||||||
|
|
||||||
all: check build
|
all: check build
|
||||||
|
|
||||||
build:
|
# ensure-web-dist creates placeholder files so //go:embed dist/* in
|
||||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/chatd
|
# 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.
|
||||||
|
ensure-web-dist:
|
||||||
|
@if [ ! -d web/dist ]; then \
|
||||||
|
mkdir -p web/dist && \
|
||||||
|
touch web/dist/index.html web/dist/style.css web/dist/app.js && \
|
||||||
|
echo "==> Created placeholder web/dist/ for go:embed"; \
|
||||||
|
fi
|
||||||
|
|
||||||
lint:
|
build: ensure-web-dist
|
||||||
|
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
|
||||||
|
|
||||||
|
lint: ensure-web-dist
|
||||||
golangci-lint run --config .golangci.yml ./...
|
golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
@@ -20,14 +31,14 @@ fmt:
|
|||||||
fmt-check:
|
fmt-check:
|
||||||
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
|
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
|
||||||
|
|
||||||
test:
|
test: ensure-web-dist
|
||||||
go test -timeout 30s -v -race -cover ./...
|
go test -timeout 30s -race -cover ./... || go test -timeout 30s -race -v ./...
|
||||||
|
|
||||||
# check runs all validation without making changes
|
# check runs all validation without making changes
|
||||||
# Used by CI and Docker build — fails if anything is wrong
|
# Used by CI and Docker build — fails if anything is wrong
|
||||||
check: test lint fmt-check
|
check: test lint fmt-check
|
||||||
@echo "==> Building..."
|
@echo "==> Building..."
|
||||||
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/chatd
|
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/neoircd
|
||||||
@echo "==> All checks passed!"
|
@echo "==> All checks passed!"
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
@@ -37,10 +48,10 @@ debug: build
|
|||||||
DEBUG=1 GOTRACEBACK=all ./bin/$(BINARY)
|
DEBUG=1 GOTRACEBACK=all ./bin/$(BINARY)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf bin/ chatd data.db
|
rm -rf bin/ neoircd
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
docker build -t chat .
|
docker build -t neoirc .
|
||||||
|
|
||||||
hooks:
|
hooks:
|
||||||
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
|
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-02-22
|
last_modified: 2026-03-09
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -98,6 +98,13 @@ style conventions are in separate documents:
|
|||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||||
a new repo.
|
a new repo.
|
||||||
|
|
||||||
|
- **No build artifacts in version control.** Code-derived data (compiled
|
||||||
|
bundles, minified output, generated assets) must never be committed to the
|
||||||
|
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
|
||||||
|
should generate these at build time. Notable exception: Go protobuf generated
|
||||||
|
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
|
||||||
|
downloads code but does not execute code generation.
|
||||||
|
|
||||||
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||||
|
|
||||||
- Never force-push to `main`.
|
- Never force-push to `main`.
|
||||||
@@ -144,8 +151,14 @@ style conventions are in separate documents:
|
|||||||
- Use SemVer.
|
- Use SemVer.
|
||||||
|
|
||||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||||
the binary. Pre-1.0.0: modify existing migrations (no installed base assumed).
|
the binary.
|
||||||
Post-1.0.0: add new migration files.
|
- `000_migration.sql` — contains ONLY the creation of the migrations
|
||||||
|
tracking table itself. Nothing else.
|
||||||
|
- `001_schema.sql` — the full application schema.
|
||||||
|
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
||||||
|
There is no installed base to migrate. Edit `001_schema.sql` directly.
|
||||||
|
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
||||||
|
Never edit existing migrations after release.
|
||||||
|
|
||||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||||
settings.
|
settings.
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
// Package main is the entry point for the chatd server.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"git.eeqj.de/sneak/chat/internal/config"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/db"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/handlers"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/healthcheck"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/logger"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/middleware"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/server"
|
|
||||||
"go.uber.org/fx"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// Appname is the application name, set at build time.
|
|
||||||
Appname = "chat" //nolint:gochecknoglobals
|
|
||||||
|
|
||||||
// Version is the application version, set at build time.
|
|
||||||
Version string //nolint:gochecknoglobals
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
globals.Appname = Appname
|
|
||||||
globals.Version = Version
|
|
||||||
|
|
||||||
fx.New(
|
|
||||||
fx.Provide(
|
|
||||||
config.New,
|
|
||||||
db.New,
|
|
||||||
globals.New,
|
|
||||||
handlers.New,
|
|
||||||
logger.New,
|
|
||||||
server.New,
|
|
||||||
middleware.New,
|
|
||||||
healthcheck.New,
|
|
||||||
),
|
|
||||||
fx.Invoke(func(*server.Server) {}),
|
|
||||||
).Run()
|
|
||||||
}
|
|
||||||
8
cmd/neoirc-cli/main.go
Normal file
8
cmd/neoirc-cli/main.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// Package main is the entry point for the neoirc-cli client.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "sneak.berlin/go/neoirc/internal/cli"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cli.Run()
|
||||||
|
}
|
||||||
53
cmd/neoircd/main.go
Normal file
53
cmd/neoircd/main.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// Package main is the entry point for the neoircd server.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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/handlers"
|
||||||
|
"sneak.berlin/go/neoirc/internal/healthcheck"
|
||||||
|
"sneak.berlin/go/neoirc/internal/ircserver"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
"sneak.berlin/go/neoirc/internal/middleware"
|
||||||
|
"sneak.berlin/go/neoirc/internal/server"
|
||||||
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
|
"sneak.berlin/go/neoirc/internal/stats"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Appname is the application name, set at build time.
|
||||||
|
Appname = "neoirc" //nolint:gochecknoglobals
|
||||||
|
|
||||||
|
// Version is the application version, set at build time.
|
||||||
|
Version string //nolint:gochecknoglobals
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
globals.Appname = Appname
|
||||||
|
globals.Version = Version
|
||||||
|
|
||||||
|
fx.New(
|
||||||
|
fx.Provide(
|
||||||
|
broker.New,
|
||||||
|
config.New,
|
||||||
|
db.New,
|
||||||
|
globals.New,
|
||||||
|
handlers.New,
|
||||||
|
ircserver.New,
|
||||||
|
logger.New,
|
||||||
|
server.New,
|
||||||
|
middleware.New,
|
||||||
|
healthcheck.New,
|
||||||
|
service.New,
|
||||||
|
stats.New,
|
||||||
|
),
|
||||||
|
fx.Invoke(func(
|
||||||
|
_ *server.Server,
|
||||||
|
_ *ircserver.Server,
|
||||||
|
) {
|
||||||
|
}),
|
||||||
|
).Run()
|
||||||
|
}
|
||||||
5
go.mod
5
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module git.eeqj.de/sneak/chat
|
module sneak.berlin/go/neoirc
|
||||||
|
|
||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ require (
|
|||||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||||
github.com/gdamore/tcell/v2 v2.13.8
|
github.com/gdamore/tcell/v2 v2.13.8
|
||||||
github.com/getsentry/sentry-go v0.42.0
|
github.com/getsentry/sentry-go v0.42.0
|
||||||
github.com/go-chi/chi v1.5.5
|
github.com/go-chi/chi/v5 v5.2.1
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
@@ -16,6 +16,7 @@ require (
|
|||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
go.uber.org/fx v1.24.0
|
go.uber.org/fx v1.24.0
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.48.0
|
||||||
|
golang.org/x/time v0.6.0
|
||||||
modernc.org/sqlite v1.45.0
|
modernc.org/sqlite v1.45.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -18,8 +18,8 @@ github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3Rl
|
|||||||
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||||
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
|
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
|
||||||
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
||||||
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
|
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||||
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
|
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
@@ -151,6 +151,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||||
|
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/chat/internal/broker"
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewBroker(t *testing.T) {
|
func TestNewBroker(t *testing.T) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package chatapi provides a client for the chat server API.
|
// Package neoircapi provides a client for the neoirc server API.
|
||||||
package chatapi
|
package neoircapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -9,10 +9,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -23,31 +26,51 @@ const (
|
|||||||
|
|
||||||
var errHTTP = errors.New("HTTP error")
|
var errHTTP = errors.New("HTTP error")
|
||||||
|
|
||||||
// Client wraps HTTP calls to the chat server API.
|
// Client wraps HTTP calls to the neoirc server API.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
Token string
|
|
||||||
HTTPClient *http.Client
|
HTTPClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a new API client.
|
// NewClient creates a new API client with a cookie jar
|
||||||
|
// for automatic auth cookie management.
|
||||||
func NewClient(baseURL string) *Client {
|
func NewClient(baseURL string) *Client {
|
||||||
return &Client{ //nolint:exhaustruct // Token set after CreateSession
|
jar, _ := cookiejar.New(nil)
|
||||||
|
|
||||||
|
return &Client{
|
||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine
|
HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine
|
||||||
Timeout: httpTimeout,
|
Timeout: httpTimeout,
|
||||||
|
Jar: jar,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSession creates a new session on the server.
|
// CreateSession creates a new session on the server.
|
||||||
|
// If the server requires hashcash proof-of-work, it
|
||||||
|
// automatically fetches the difficulty and computes a
|
||||||
|
// valid stamp.
|
||||||
func (client *Client) CreateSession(
|
func (client *Client) CreateSession(
|
||||||
nick string,
|
nick string,
|
||||||
) (*SessionResponse, error) {
|
) (*SessionResponse, error) {
|
||||||
|
// Fetch server info to check for hashcash requirement.
|
||||||
|
info, err := client.GetServerInfo()
|
||||||
|
|
||||||
|
var hashcashStamp string
|
||||||
|
|
||||||
|
if err == nil && info.HashcashBits > 0 {
|
||||||
|
resource := info.Name
|
||||||
|
if resource == "" {
|
||||||
|
resource = "neoirc"
|
||||||
|
}
|
||||||
|
|
||||||
|
hashcashStamp = MintHashcash(info.HashcashBits, resource)
|
||||||
|
}
|
||||||
|
|
||||||
data, err := client.do(
|
data, err := client.do(
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
"/api/v1/session",
|
"/api/v1/session",
|
||||||
&SessionRequest{Nick: nick},
|
&SessionRequest{Nick: nick, Hashcash: hashcashStamp},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -60,8 +83,6 @@ func (client *Client) CreateSession(
|
|||||||
return nil, fmt.Errorf("decode session: %w", err)
|
return nil, fmt.Errorf("decode session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
client.Token = resp.Token
|
|
||||||
|
|
||||||
return &resp, nil
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +123,7 @@ func (client *Client) PollMessages(
|
|||||||
Timeout: time.Duration(
|
Timeout: time.Duration(
|
||||||
timeout+pollExtraTime,
|
timeout+pollExtraTime,
|
||||||
) * time.Second,
|
) * time.Second,
|
||||||
|
Jar: client.HTTPClient.Jar,
|
||||||
}
|
}
|
||||||
|
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
@@ -126,10 +148,6 @@ func (client *Client) PollMessages(
|
|||||||
return nil, fmt.Errorf("new request: %w", err)
|
return nil, fmt.Errorf("new request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
request.Header.Set(
|
|
||||||
"Authorization", "Bearer "+client.Token,
|
|
||||||
)
|
|
||||||
|
|
||||||
resp, err := pollClient.Do(request)
|
resp, err := pollClient.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("poll request: %w", err)
|
return nil, fmt.Errorf("poll request: %w", err)
|
||||||
@@ -168,7 +186,7 @@ func (client *Client) PollMessages(
|
|||||||
func (client *Client) JoinChannel(channel string) error {
|
func (client *Client) JoinChannel(channel string) error {
|
||||||
return client.SendMessage(
|
return client.SendMessage(
|
||||||
&Message{ //nolint:exhaustruct // only command+to needed
|
&Message{ //nolint:exhaustruct // only command+to needed
|
||||||
Command: "JOIN", To: channel,
|
Command: irc.CmdJoin, To: channel,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -177,7 +195,7 @@ func (client *Client) JoinChannel(channel string) error {
|
|||||||
func (client *Client) PartChannel(channel string) error {
|
func (client *Client) PartChannel(channel string) error {
|
||||||
return client.SendMessage(
|
return client.SendMessage(
|
||||||
&Message{ //nolint:exhaustruct // only command+to needed
|
&Message{ //nolint:exhaustruct // only command+to needed
|
||||||
Command: "PART", To: channel,
|
Command: irc.CmdPart, To: channel,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -285,12 +303,6 @@ func (client *Client) do(
|
|||||||
"Content-Type", "application/json",
|
"Content-Type", "application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
if client.Token != "" {
|
|
||||||
request.Header.Set(
|
|
||||||
"Authorization", "Bearer "+client.Token,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.HTTPClient.Do(request)
|
resp, err := client.HTTPClient.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("http: %w", err)
|
return nil, fmt.Errorf("http: %w", err)
|
||||||
98
internal/cli/api/hashcash.go
Normal file
98
internal/cli/api/hashcash.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package neoircapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// bitsPerByte is the number of bits in a byte.
|
||||||
|
bitsPerByte = 8
|
||||||
|
// fullByteMask is 0xFF, a mask for all bits in a byte.
|
||||||
|
fullByteMask = 0xFF
|
||||||
|
// counterSpace is the range for random counter seeds.
|
||||||
|
counterSpace = 1 << 48
|
||||||
|
)
|
||||||
|
|
||||||
|
// MintHashcash computes a hashcash stamp with the given
|
||||||
|
// difficulty (leading zero bits) and resource string.
|
||||||
|
func MintHashcash(bits int, resource string) string {
|
||||||
|
date := time.Now().UTC().Format("060102")
|
||||||
|
prefix := fmt.Sprintf(
|
||||||
|
"1:%d:%s:%s::", bits, date, resource,
|
||||||
|
)
|
||||||
|
|
||||||
|
for {
|
||||||
|
counter := randomCounter()
|
||||||
|
stamp := prefix + counter
|
||||||
|
hash := sha256.Sum256([]byte(stamp))
|
||||||
|
|
||||||
|
if hasLeadingZeroBits(hash[:], bits) {
|
||||||
|
return stamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MintChannelHashcash computes a hashcash stamp bound to
|
||||||
|
// a specific channel and message body. The stamp format
|
||||||
|
// is 1:bits:YYMMDD:channel:bodyhash:counter where
|
||||||
|
// bodyhash is the hex-encoded SHA-256 of the message
|
||||||
|
// body bytes. Delegates to the internal/hashcash package.
|
||||||
|
func MintChannelHashcash(
|
||||||
|
bits int,
|
||||||
|
channel string,
|
||||||
|
body []byte,
|
||||||
|
) string {
|
||||||
|
bodyHash := hashcash.BodyHash(body)
|
||||||
|
|
||||||
|
return hashcash.MintChannelStamp(
|
||||||
|
bits, channel, bodyHash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasLeadingZeroBits checks if hash has at least numBits
|
||||||
|
// leading zero bits.
|
||||||
|
func hasLeadingZeroBits(
|
||||||
|
hash []byte,
|
||||||
|
numBits int,
|
||||||
|
) bool {
|
||||||
|
fullBytes := numBits / bitsPerByte
|
||||||
|
remainBits := numBits % bitsPerByte
|
||||||
|
|
||||||
|
for idx := range fullBytes {
|
||||||
|
if hash[idx] != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if remainBits > 0 && fullBytes < len(hash) {
|
||||||
|
mask := byte(
|
||||||
|
fullByteMask << (bitsPerByte - remainBits),
|
||||||
|
)
|
||||||
|
|
||||||
|
if hash[fullBytes]&mask != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomCounter generates a random hex counter string.
|
||||||
|
func randomCounter() string {
|
||||||
|
counterVal, err := rand.Int(
|
||||||
|
rand.Reader, big.NewInt(counterSpace),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
// Fallback to timestamp-based counter on error.
|
||||||
|
return fmt.Sprintf("%x", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
return hex.EncodeToString(counterVal.Bytes())
|
||||||
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
package chatapi
|
package neoircapi
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// SessionRequest is the body for POST /api/v1/session.
|
// SessionRequest is the body for POST /api/v1/session.
|
||||||
type SessionRequest struct {
|
type SessionRequest struct {
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
|
Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionResponse is the response from session creation.
|
// SessionResponse is the response from session creation.
|
||||||
type SessionResponse struct {
|
type SessionResponse struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
Token string `json:"token"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateResponse is the response from GET /api/v1/state.
|
// StateResponse is the response from GET /api/v1/state.
|
||||||
@@ -21,7 +21,7 @@ type StateResponse struct {
|
|||||||
Channels []string `json:"channels"`
|
Channels []string `json:"channels"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message represents a chat message envelope.
|
// Message represents a neoirc message envelope.
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
From string `json:"from,omitempty"`
|
From string `json:"from,omitempty"`
|
||||||
@@ -66,6 +66,7 @@ type ServerInfo struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
MOTD string `json:"motd"`
|
MOTD string `json:"motd"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
|
HashcashBits int `json:"hashcash_bits"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
|
|
||||||
// MessagesResponse wraps polling results.
|
// MessagesResponse wraps polling results.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package main is the entry point for the chat-cli client.
|
// Package cli implements the neoirc-cli terminal client.
|
||||||
package main
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -8,7 +8,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
api "git.eeqj.de/sneak/chat/cmd/chat-cli/api"
|
api "sneak.berlin/go/neoirc/internal/cli/api"
|
||||||
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -31,7 +32,8 @@ type App struct {
|
|||||||
stopPoll chan struct{}
|
stopPoll chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
// Run creates and runs the CLI application.
|
||||||
|
func Run() {
|
||||||
app := &App{ //nolint:exhaustruct
|
app := &App{ //nolint:exhaustruct
|
||||||
ui: NewUI(),
|
ui: NewUI(),
|
||||||
nick: "guest",
|
nick: "guest",
|
||||||
@@ -41,7 +43,7 @@ func main() {
|
|||||||
app.ui.SetStatus(app.nick, "", "disconnected")
|
app.ui.SetStatus(app.nick, "", "disconnected")
|
||||||
|
|
||||||
app.ui.AddStatus(
|
app.ui.AddStatus(
|
||||||
"Welcome to chat-cli — an IRC-style client",
|
"Welcome to neoirc-cli — an IRC-style client",
|
||||||
)
|
)
|
||||||
app.ui.AddStatus(
|
app.ui.AddStatus(
|
||||||
"Type [yellow]/connect <server-url>" +
|
"Type [yellow]/connect <server-url>" +
|
||||||
@@ -86,7 +88,7 @@ func (a *App) handleInput(text string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: "PRIVMSG",
|
Command: irc.CmdPrivmsg,
|
||||||
To: target,
|
To: target,
|
||||||
Body: []string{text},
|
Body: []string{text},
|
||||||
})
|
})
|
||||||
@@ -138,16 +140,29 @@ func (a *App) dispatchCommand(cmd, args string) {
|
|||||||
a.cmdQuery(args)
|
a.cmdQuery(args)
|
||||||
case "/topic":
|
case "/topic":
|
||||||
a.cmdTopic(args)
|
a.cmdTopic(args)
|
||||||
case "/names":
|
|
||||||
a.cmdNames()
|
|
||||||
case "/list":
|
|
||||||
a.cmdList()
|
|
||||||
case "/window", "/w":
|
case "/window", "/w":
|
||||||
a.cmdWindow(args)
|
a.cmdWindow(args)
|
||||||
case "/quit":
|
case "/quit":
|
||||||
a.cmdQuit()
|
a.cmdQuit()
|
||||||
case "/help":
|
case "/help":
|
||||||
a.cmdHelp()
|
a.cmdHelp()
|
||||||
|
default:
|
||||||
|
a.dispatchInfoCommand(cmd, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) dispatchInfoCommand(cmd, args string) {
|
||||||
|
switch cmd {
|
||||||
|
case "/names":
|
||||||
|
a.cmdNames()
|
||||||
|
case "/list":
|
||||||
|
a.cmdList()
|
||||||
|
case "/motd":
|
||||||
|
a.cmdMotd()
|
||||||
|
case "/who":
|
||||||
|
a.cmdWho(args)
|
||||||
|
case "/whois":
|
||||||
|
a.cmdWhois(args)
|
||||||
default:
|
default:
|
||||||
a.ui.AddStatus(
|
a.ui.AddStatus(
|
||||||
"[red]Unknown command: " + cmd,
|
"[red]Unknown command: " + cmd,
|
||||||
@@ -228,7 +243,7 @@ func (a *App) cmdNick(nick string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: "NICK",
|
Command: irc.CmdNick,
|
||||||
Body: []string{nick},
|
Body: []string{nick},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -363,7 +378,7 @@ func (a *App) cmdMsg(args string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: "PRIVMSG",
|
Command: irc.CmdPrivmsg,
|
||||||
To: target,
|
To: target,
|
||||||
Body: []string{text},
|
Body: []string{text},
|
||||||
})
|
})
|
||||||
@@ -421,7 +436,7 @@ func (a *App) cmdTopic(args string) {
|
|||||||
|
|
||||||
if args == "" {
|
if args == "" {
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: "TOPIC",
|
Command: irc.CmdTopic,
|
||||||
To: target,
|
To: target,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -434,7 +449,7 @@ func (a *App) cmdTopic(args string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: "TOPIC",
|
Command: irc.CmdTopic,
|
||||||
To: target,
|
To: target,
|
||||||
Body: []string{args},
|
Body: []string{args},
|
||||||
})
|
})
|
||||||
@@ -510,6 +525,96 @@ func (a *App) cmdList() {
|
|||||||
a.ui.AddStatus("[cyan]*** End of channel list")
|
a.ui.AddStatus("[cyan]*** End of channel list")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdMotd() {
|
||||||
|
a.mu.Lock()
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.SendMessage(
|
||||||
|
&api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[red]MOTD failed: %v", err,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdWho(args string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
connected := a.connected
|
||||||
|
target := a.target
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
channel := args
|
||||||
|
if channel == "" {
|
||||||
|
channel = target
|
||||||
|
}
|
||||||
|
|
||||||
|
if channel == "" ||
|
||||||
|
!strings.HasPrefix(channel, "#") {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
"[red]Usage: /who #channel",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.SendMessage(
|
||||||
|
&api.Message{ //nolint:exhaustruct
|
||||||
|
Command: irc.CmdWho, To: channel,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[red]WHO failed: %v", err,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdWhois(args string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if args == "" {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
"[red]Usage: /whois <nick>",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.SendMessage(
|
||||||
|
&api.Message{ //nolint:exhaustruct
|
||||||
|
Command: irc.CmdWhois, To: args,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
|
"[red]WHOIS failed: %v", err,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) cmdWindow(args string) {
|
func (a *App) cmdWindow(args string) {
|
||||||
if args == "" {
|
if args == "" {
|
||||||
a.ui.AddStatus(
|
a.ui.AddStatus(
|
||||||
@@ -550,7 +655,7 @@ func (a *App) cmdQuit() {
|
|||||||
|
|
||||||
if a.connected && a.client != nil {
|
if a.connected && a.client != nil {
|
||||||
_ = a.client.SendMessage(
|
_ = a.client.SendMessage(
|
||||||
&api.Message{Command: "QUIT"}, //nolint:exhaustruct
|
&api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,7 +669,7 @@ func (a *App) cmdQuit() {
|
|||||||
|
|
||||||
func (a *App) cmdHelp() {
|
func (a *App) cmdHelp() {
|
||||||
help := []string{
|
help := []string{
|
||||||
"[cyan]*** chat-cli commands:",
|
"[cyan]*** neoirc-cli commands:",
|
||||||
" /connect <url> — Connect to server",
|
" /connect <url> — Connect to server",
|
||||||
" /nick <name> — Change nickname",
|
" /nick <name> — Change nickname",
|
||||||
" /join #channel — Join channel",
|
" /join #channel — Join channel",
|
||||||
@@ -574,6 +679,9 @@ func (a *App) cmdHelp() {
|
|||||||
" /topic [text] — View/set topic",
|
" /topic [text] — View/set topic",
|
||||||
" /names — List channel members",
|
" /names — List channel members",
|
||||||
" /list — List channels",
|
" /list — List channels",
|
||||||
|
" /who [#channel] — List users in channel",
|
||||||
|
" /whois <nick> — Show user info",
|
||||||
|
" /motd — Show message of the day",
|
||||||
" /window <n> — Switch buffer",
|
" /window <n> — Switch buffer",
|
||||||
" /quit — Disconnect and exit",
|
" /quit — Disconnect and exit",
|
||||||
" /help — This help",
|
" /help — This help",
|
||||||
@@ -632,19 +740,19 @@ func (a *App) handleServerMessage(msg *api.Message) {
|
|||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
switch msg.Command {
|
switch msg.Command {
|
||||||
case "PRIVMSG":
|
case irc.CmdPrivmsg:
|
||||||
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
||||||
case "JOIN":
|
case irc.CmdJoin:
|
||||||
a.handleJoinEvent(msg, timestamp)
|
a.handleJoinEvent(msg, timestamp)
|
||||||
case "PART":
|
case irc.CmdPart:
|
||||||
a.handlePartEvent(msg, timestamp)
|
a.handlePartEvent(msg, timestamp)
|
||||||
case "QUIT":
|
case irc.CmdQuit:
|
||||||
a.handleQuitEvent(msg, timestamp)
|
a.handleQuitEvent(msg, timestamp)
|
||||||
case "NICK":
|
case irc.CmdNick:
|
||||||
a.handleNickEvent(msg, timestamp, myNick)
|
a.handleNickEvent(msg, timestamp, myNick)
|
||||||
case "NOTICE":
|
case irc.CmdNotice:
|
||||||
a.handleNoticeEvent(msg, timestamp)
|
a.handleNoticeEvent(msg, timestamp)
|
||||||
case "TOPIC":
|
case irc.CmdTopic:
|
||||||
a.handleTopicEvent(msg, timestamp)
|
a.handleTopicEvent(msg, timestamp)
|
||||||
default:
|
default:
|
||||||
a.handleDefaultEvent(msg, timestamp)
|
a.handleDefaultEvent(msg, timestamp)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -5,14 +5,22 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/chat/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/logger"
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultMOTD = ` _ __ ___ ___ (_)_ __ ___
|
||||||
|
| '_ \ / _ \/ _ \ | | '__/ __|
|
||||||
|
| | | | __/ (_) || | | | (__
|
||||||
|
|_| |_|\___|\___/ |_|_| \___|
|
||||||
|
|
||||||
|
Welcome to NeoIRC — IRC semantics over HTTP.
|
||||||
|
Type /help for available commands.`
|
||||||
|
|
||||||
// Params defines the dependencies for creating a Config.
|
// Params defines the dependencies for creating a Config.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
@@ -30,12 +38,19 @@ type Config struct {
|
|||||||
MetricsUsername string
|
MetricsUsername string
|
||||||
Port int
|
Port int
|
||||||
SentryDSN string
|
SentryDSN string
|
||||||
MaxHistory int
|
MessageMaxAge string
|
||||||
MaxMessageSize int
|
MaxMessageSize int
|
||||||
|
QueueMaxAge string
|
||||||
MOTD string
|
MOTD string
|
||||||
ServerName string
|
ServerName string
|
||||||
FederationKey string
|
FederationKey string
|
||||||
SessionIdleTimeout string
|
SessionIdleTimeout string
|
||||||
|
HashcashBits int
|
||||||
|
OperName string
|
||||||
|
OperPassword string
|
||||||
|
LoginRateLimit float64
|
||||||
|
LoginRateBurst int
|
||||||
|
IRCListenAddr string
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -56,16 +71,23 @@ func New(
|
|||||||
viper.SetDefault("DEBUG", "false")
|
viper.SetDefault("DEBUG", "false")
|
||||||
viper.SetDefault("MAINTENANCE_MODE", "false")
|
viper.SetDefault("MAINTENANCE_MODE", "false")
|
||||||
viper.SetDefault("PORT", "8080")
|
viper.SetDefault("PORT", "8080")
|
||||||
viper.SetDefault("DBURL", "file:./data.db?_journal_mode=WAL")
|
viper.SetDefault("DBURL", "file:///var/lib/neoirc/state.db?_journal_mode=WAL")
|
||||||
viper.SetDefault("SENTRY_DSN", "")
|
viper.SetDefault("SENTRY_DSN", "")
|
||||||
viper.SetDefault("METRICS_USERNAME", "")
|
viper.SetDefault("METRICS_USERNAME", "")
|
||||||
viper.SetDefault("METRICS_PASSWORD", "")
|
viper.SetDefault("METRICS_PASSWORD", "")
|
||||||
viper.SetDefault("MAX_HISTORY", "10000")
|
viper.SetDefault("MESSAGE_MAX_AGE", "720h")
|
||||||
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
|
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
|
||||||
viper.SetDefault("MOTD", "")
|
viper.SetDefault("QUEUE_MAX_AGE", "720h")
|
||||||
|
viper.SetDefault("MOTD", defaultMOTD)
|
||||||
viper.SetDefault("SERVER_NAME", "")
|
viper.SetDefault("SERVER_NAME", "")
|
||||||
viper.SetDefault("FEDERATION_KEY", "")
|
viper.SetDefault("FEDERATION_KEY", "")
|
||||||
viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h")
|
viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h")
|
||||||
|
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
|
||||||
|
viper.SetDefault("NEOIRC_OPER_NAME", "")
|
||||||
|
viper.SetDefault("NEOIRC_OPER_PASSWORD", "")
|
||||||
|
viper.SetDefault("LOGIN_RATE_LIMIT", "1")
|
||||||
|
viper.SetDefault("LOGIN_RATE_BURST", "5")
|
||||||
|
viper.SetDefault("IRC_LISTEN_ADDR", ":6667")
|
||||||
|
|
||||||
err := viper.ReadInConfig()
|
err := viper.ReadInConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -84,12 +106,19 @@ func New(
|
|||||||
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
||||||
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||||
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
||||||
MaxHistory: viper.GetInt("MAX_HISTORY"),
|
MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"),
|
||||||
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
|
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
|
||||||
|
QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"),
|
||||||
MOTD: viper.GetString("MOTD"),
|
MOTD: viper.GetString("MOTD"),
|
||||||
ServerName: viper.GetString("SERVER_NAME"),
|
ServerName: viper.GetString("SERVER_NAME"),
|
||||||
FederationKey: viper.GetString("FEDERATION_KEY"),
|
FederationKey: viper.GetString("FEDERATION_KEY"),
|
||||||
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
||||||
|
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
|
||||||
|
OperName: viper.GetString("NEOIRC_OPER_NAME"),
|
||||||
|
OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"),
|
||||||
|
LoginRateLimit: viper.GetFloat64("LOGIN_RATE_LIMIT"),
|
||||||
|
LoginRateBurst: viper.GetInt("LOGIN_RATE_BURST"),
|
||||||
|
IRCListenAddr: viper.GetString("IRC_LISTEN_ADDR"),
|
||||||
log: log,
|
log: log,
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,91 +10,46 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
const bcryptCost = bcrypt.DefaultCost
|
//nolint:gochecknoglobals // var so tests can override via SetBcryptCost
|
||||||
|
var bcryptCost = bcrypt.DefaultCost
|
||||||
|
|
||||||
|
// SetBcryptCost overrides the bcrypt cost.
|
||||||
|
// Use bcrypt.MinCost in tests to avoid slow hashing.
|
||||||
|
func SetBcryptCost(cost int) { bcryptCost = cost }
|
||||||
|
|
||||||
var errNoPassword = errors.New(
|
var errNoPassword = errors.New(
|
||||||
"account has no password set",
|
"account has no password set",
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterUser creates a session with a hashed password
|
// SetPassword sets a bcrypt-hashed password on a session,
|
||||||
// and returns session ID, client ID, and token.
|
// enabling multi-client login via POST /api/v1/login.
|
||||||
func (database *Database) RegisterUser(
|
func (database *Database) SetPassword(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nick, password string,
|
sessionID int64,
|
||||||
) (int64, int64, string, error) {
|
password string,
|
||||||
|
) error {
|
||||||
hash, err := bcrypt.GenerateFromPassword(
|
hash, err := bcrypt.GenerateFromPassword(
|
||||||
[]byte(password), bcryptCost,
|
[]byte(password), bcryptCost,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, "", fmt.Errorf(
|
return fmt.Errorf("hash password: %w", err)
|
||||||
"hash password: %w", err,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionUUID := uuid.New().String()
|
_, err = database.conn.ExecContext(ctx,
|
||||||
clientUUID := uuid.New().String()
|
"UPDATE sessions SET password_hash = ? WHERE id = ?",
|
||||||
|
string(hash), sessionID)
|
||||||
token, err := generateToken()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, "", err
|
return fmt.Errorf("set password: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
return nil
|
||||||
|
|
||||||
transaction, err := database.conn.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, "", fmt.Errorf(
|
|
||||||
"begin tx: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := transaction.ExecContext(ctx,
|
|
||||||
`INSERT INTO sessions
|
|
||||||
(uuid, nick, password_hash,
|
|
||||||
created_at, last_seen)
|
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
sessionUUID, nick, string(hash), now, now)
|
|
||||||
if err != nil {
|
|
||||||
_ = transaction.Rollback()
|
|
||||||
|
|
||||||
return 0, 0, "", fmt.Errorf(
|
|
||||||
"create session: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionID, _ := res.LastInsertId()
|
|
||||||
|
|
||||||
clientRes, err := transaction.ExecContext(ctx,
|
|
||||||
`INSERT INTO clients
|
|
||||||
(uuid, session_id, token,
|
|
||||||
created_at, last_seen)
|
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
clientUUID, sessionID, token, now, now)
|
|
||||||
if err != nil {
|
|
||||||
_ = transaction.Rollback()
|
|
||||||
|
|
||||||
return 0, 0, "", fmt.Errorf(
|
|
||||||
"create client: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
clientID, _ := clientRes.LastInsertId()
|
|
||||||
|
|
||||||
err = transaction.Commit()
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, "", fmt.Errorf(
|
|
||||||
"commit registration: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return sessionID, clientID, token, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginUser verifies a nick/password and creates a new
|
// LoginUser verifies a nick/password and creates a new
|
||||||
// client token.
|
// client token.
|
||||||
func (database *Database) LoginUser(
|
func (database *Database) LoginUser(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nick, password string,
|
nick, password, remoteIP, hostname string,
|
||||||
) (int64, int64, string, error) {
|
) (int64, int64, string, error) {
|
||||||
var (
|
var (
|
||||||
sessionID int64
|
sessionID int64
|
||||||
@@ -137,12 +92,15 @@ func (database *Database) LoginUser(
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
|
tokenHash := hashToken(token)
|
||||||
|
|
||||||
res, err := database.conn.ExecContext(ctx,
|
res, err := database.conn.ExecContext(ctx,
|
||||||
`INSERT INTO clients
|
`INSERT INTO clients
|
||||||
(uuid, session_id, token,
|
(uuid, session_id, token, ip, hostname,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, token, now, now)
|
clientUUID, sessionID, tokenHash,
|
||||||
|
remoteIP, hostname, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, "", fmt.Errorf(
|
return 0, 0, "", fmt.Errorf(
|
||||||
"create login client: %w", err,
|
"create login client: %w", err,
|
||||||
|
|||||||
@@ -6,63 +6,65 @@ import (
|
|||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRegisterUser(t *testing.T) {
|
func TestSetPassword(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sessionID, clientID, token, err :=
|
sessionID, _, _, err :=
|
||||||
database.RegisterUser(ctx, "reguser", "password123")
|
database.CreateSession(ctx, "passuser", "", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if sessionID == 0 || clientID == 0 || token == "" {
|
err = database.SetPassword(
|
||||||
|
ctx, sessionID, "password123",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we can now log in with the password.
|
||||||
|
loginSID, loginCID, loginToken, err :=
|
||||||
|
database.LoginUser(ctx, "passuser", "password123", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loginSID == 0 || loginCID == 0 || loginToken == "" {
|
||||||
t.Fatal("expected valid ids and token")
|
t.Fatal("expected valid ids and token")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify session works via token lookup.
|
|
||||||
sid, cid, nick, err :=
|
|
||||||
database.GetSessionByToken(ctx, token)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if sid != sessionID || cid != clientID {
|
func TestSetPasswordThenWrongLogin(t *testing.T) {
|
||||||
t.Fatal("session/client id mismatch")
|
|
||||||
}
|
|
||||||
|
|
||||||
if nick != "reguser" {
|
|
||||||
t.Fatalf("expected reguser, got %s", nick)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegisterUserDuplicateNick(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
regSID, regCID, regToken, err :=
|
sessionID, _, _, err :=
|
||||||
database.RegisterUser(ctx, "dupnick", "password123")
|
database.CreateSession(ctx, "wrongpw", "", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = regSID
|
err = database.SetPassword(
|
||||||
_ = regCID
|
ctx, sessionID, "correctpass",
|
||||||
_ = regToken
|
)
|
||||||
|
if err != nil {
|
||||||
dupSID, dupCID, dupToken, dupErr :=
|
t.Fatal(err)
|
||||||
database.RegisterUser(ctx, "dupnick", "other12345")
|
|
||||||
if dupErr == nil {
|
|
||||||
t.Fatal("expected error for duplicate nick")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = dupSID
|
loginSID, loginCID, loginToken, loginErr :=
|
||||||
_ = dupCID
|
database.LoginUser(ctx, "wrongpw", "wrongpass12", "", "")
|
||||||
_ = dupToken
|
if loginErr == nil {
|
||||||
|
t.Fatal("expected error for wrong password")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = loginSID
|
||||||
|
_ = loginCID
|
||||||
|
_ = loginToken
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginUser(t *testing.T) {
|
func TestLoginUser(t *testing.T) {
|
||||||
@@ -71,23 +73,26 @@ func TestLoginUser(t *testing.T) {
|
|||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
regSID, regCID, regToken, err :=
|
sessionID, _, _, err :=
|
||||||
database.RegisterUser(ctx, "loginuser", "mypassword")
|
database.CreateSession(ctx, "loginuser", "", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = regSID
|
err = database.SetPassword(
|
||||||
_ = regCID
|
ctx, sessionID, "mypassword",
|
||||||
_ = regToken
|
)
|
||||||
|
|
||||||
sessionID, clientID, token, err :=
|
|
||||||
database.LoginUser(ctx, "loginuser", "mypassword")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if sessionID == 0 || clientID == 0 || token == "" {
|
loginSID, loginCID, token, err :=
|
||||||
|
database.LoginUser(ctx, "loginuser", "mypassword", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loginSID == 0 || loginCID == 0 || token == "" {
|
||||||
t.Fatal("expected valid ids and token")
|
t.Fatal("expected valid ids and token")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,33 +108,6 @@ func TestLoginUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginUserWrongPassword(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
regSID, regCID, regToken, err :=
|
|
||||||
database.RegisterUser(ctx, "wrongpw", "correctpass")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = regSID
|
|
||||||
_ = regCID
|
|
||||||
_ = regToken
|
|
||||||
|
|
||||||
loginSID, loginCID, loginToken, loginErr :=
|
|
||||||
database.LoginUser(ctx, "wrongpw", "wrongpass12")
|
|
||||||
if loginErr == nil {
|
|
||||||
t.Fatal("expected error for wrong password")
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = loginSID
|
|
||||||
_ = loginCID
|
|
||||||
_ = loginToken
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoginUserNoPassword(t *testing.T) {
|
func TestLoginUserNoPassword(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -138,7 +116,7 @@ func TestLoginUserNoPassword(t *testing.T) {
|
|||||||
|
|
||||||
// Create anonymous session (no password).
|
// Create anonymous session (no password).
|
||||||
anonSID, anonCID, anonToken, err :=
|
anonSID, anonCID, anonToken, err :=
|
||||||
database.CreateSession(ctx, "anon")
|
database.CreateSession(ctx, "anon", "", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -148,7 +126,7 @@ func TestLoginUserNoPassword(t *testing.T) {
|
|||||||
_ = anonToken
|
_ = anonToken
|
||||||
|
|
||||||
loginSID, loginCID, loginToken, loginErr :=
|
loginSID, loginCID, loginToken, loginErr :=
|
||||||
database.LoginUser(ctx, "anon", "anything1")
|
database.LoginUser(ctx, "anon", "anything1", "", "")
|
||||||
if loginErr == nil {
|
if loginErr == nil {
|
||||||
t.Fatal(
|
t.Fatal(
|
||||||
"expected error for login on passwordless account",
|
"expected error for login on passwordless account",
|
||||||
@@ -167,7 +145,7 @@ func TestLoginUserNonexistent(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
loginSID, loginCID, loginToken, err :=
|
loginSID, loginCID, loginToken, err :=
|
||||||
database.LoginUser(ctx, "ghost", "password123")
|
database.LoginUser(ctx, "ghost", "password123", "", "")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for nonexistent user")
|
t.Fatal("expected error for nonexistent user")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/chat/internal/config"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/logger"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload" // .env
|
_ "github.com/joho/godotenv/autoload" // .env
|
||||||
_ "modernc.org/sqlite" // driver
|
_ "modernc.org/sqlite" // driver
|
||||||
@@ -87,7 +87,7 @@ func (database *Database) GetDB() *sql.DB {
|
|||||||
func (database *Database) connect(ctx context.Context) error {
|
func (database *Database) connect(ctx context.Context) error {
|
||||||
dbURL := database.params.Config.DBURL
|
dbURL := database.params.Config.DBURL
|
||||||
if dbURL == "" {
|
if dbURL == "" {
|
||||||
dbURL = "file:./data.db?_journal_mode=WAL&_busy_timeout=5000"
|
dbURL = "file:///var/lib/neoirc/state.db?_journal_mode=WAL&_busy_timeout=5000"
|
||||||
}
|
}
|
||||||
|
|
||||||
database.log.Info(
|
database.log.Info(
|
||||||
@@ -135,13 +135,21 @@ type migration struct {
|
|||||||
func (database *Database) runMigrations(
|
func (database *Database) runMigrations(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) error {
|
) error {
|
||||||
_, err := database.conn.ExecContext(ctx,
|
bootstrap, err := SchemaFiles.ReadFile(
|
||||||
`CREATE TABLE IF NOT EXISTS schema_migrations (
|
"schema/000.sql",
|
||||||
version INTEGER PRIMARY KEY,
|
)
|
||||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)`)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"create schema_migrations: %w", err,
|
"read bootstrap migration: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = database.conn.ExecContext(
|
||||||
|
ctx, string(bootstrap),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"execute bootstrap migration: %w", err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +278,11 @@ func (database *Database) loadMigrations() (
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip bootstrap migration; it is executed separately.
|
||||||
|
if version == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
content, readErr := SchemaFiles.ReadFile(
|
content, readErr := SchemaFiles.ReadFile(
|
||||||
"schema/" + entry.Name(),
|
"schema/" + entry.Name(),
|
||||||
)
|
)
|
||||||
|
|||||||
20
internal/db/errors.go
Normal file
20
internal/db/errors.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// Package db provides database access and migration management.
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"modernc.org/sqlite"
|
||||||
|
sqlite3 "modernc.org/sqlite/lib"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsUniqueConstraintError reports whether err is a SQLite
|
||||||
|
// unique-constraint violation.
|
||||||
|
func IsUniqueConstraintError(err error) bool {
|
||||||
|
var sqliteErr *sqlite.Error
|
||||||
|
if !errors.As(err, &sqliteErr) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE
|
||||||
|
}
|
||||||
@@ -58,3 +58,19 @@ func (database *Database) Close() error {
|
|||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
14
internal/db/main_test.go
Normal file
14
internal/db/main_test.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package db_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
db.SetBcryptCost(bcrypt.MinCost)
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
6
internal/db/schema/000.sql
Normal file
6
internal/db/schema/000.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Bootstrap: create the schema_migrations table itself.
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
|
||||||
@@ -6,8 +6,14 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
uuid TEXT NOT NULL UNIQUE,
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
nick TEXT NOT NULL UNIQUE,
|
nick TEXT NOT NULL UNIQUE,
|
||||||
|
username TEXT NOT NULL DEFAULT '',
|
||||||
|
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 '',
|
password_hash TEXT NOT NULL DEFAULT '',
|
||||||
signing_key TEXT NOT NULL DEFAULT '',
|
signing_key TEXT NOT NULL DEFAULT '',
|
||||||
|
away_message TEXT NOT NULL DEFAULT '',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -19,6 +25,8 @@ CREATE TABLE IF NOT EXISTS clients (
|
|||||||
uuid TEXT NOT NULL UNIQUE,
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
token TEXT NOT NULL UNIQUE,
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
ip TEXT NOT NULL DEFAULT '',
|
||||||
|
hostname TEXT NOT NULL DEFAULT '',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -30,15 +38,49 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
topic TEXT NOT NULL DEFAULT '',
|
topic TEXT NOT NULL DEFAULT '',
|
||||||
|
topic_set_by TEXT NOT NULL DEFAULT '',
|
||||||
|
topic_set_at DATETIME,
|
||||||
|
hashcash_bits INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_moderated INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_topic_locked INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_invite_only INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_secret INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_no_external INTEGER NOT NULL DEFAULT 1,
|
||||||
|
channel_key TEXT NOT NULL DEFAULT '',
|
||||||
|
user_limit INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Channel bans
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_bans (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
|
mask TEXT NOT NULL,
|
||||||
|
set_by TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(channel_id, mask)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_bans_channel ON channel_bans(channel_id);
|
||||||
|
|
||||||
|
-- Channel invites (in-memory would be simpler but DB survives restarts)
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_invites (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
|
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
invited_by TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(channel_id, session_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_invites_channel ON channel_invites(channel_id);
|
||||||
|
|
||||||
-- Channel members
|
-- Channel members
|
||||||
CREATE TABLE IF NOT EXISTS channel_members (
|
CREATE TABLE IF NOT EXISTS channel_members (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
is_operator INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_voiced INTEGER NOT NULL DEFAULT 0,
|
||||||
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(channel_id, session_id)
|
UNIQUE(channel_id, session_id)
|
||||||
);
|
);
|
||||||
@@ -50,6 +92,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||||||
command TEXT NOT NULL DEFAULT 'PRIVMSG',
|
command TEXT NOT NULL DEFAULT 'PRIVMSG',
|
||||||
msg_from TEXT NOT NULL DEFAULT '',
|
msg_from TEXT NOT NULL DEFAULT '',
|
||||||
msg_to TEXT NOT NULL DEFAULT '',
|
msg_to TEXT NOT NULL DEFAULT '',
|
||||||
|
params TEXT NOT NULL DEFAULT '[]',
|
||||||
body TEXT NOT NULL DEFAULT '[]',
|
body TEXT NOT NULL DEFAULT '[]',
|
||||||
meta TEXT NOT NULL DEFAULT '{}',
|
meta TEXT NOT NULL DEFAULT '{}',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
@@ -57,6 +100,14 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(msg_to, id);
|
CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(msg_to, id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
|
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
|
||||||
|
|
||||||
|
-- Spent hashcash tokens for replay prevention (1-year TTL)
|
||||||
|
CREATE TABLE IF NOT EXISTS spent_hashcash (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
stamp_hash TEXT NOT NULL UNIQUE,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spent_hashcash_created ON spent_hashcash(created_at);
|
||||||
|
|
||||||
-- Per-client message queues for fan-out delivery
|
-- Per-client message queues for fan-out delivery
|
||||||
CREATE TABLE IF NOT EXISTS client_queues (
|
CREATE TABLE IF NOT EXISTS client_queues (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
25
internal/db/testing.go
Normal file
25
internal/db/testing.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewTestDatabaseFromConn creates a Database wrapping an
|
||||||
|
// existing *sql.DB connection. Intended for integration
|
||||||
|
// tests in other packages.
|
||||||
|
func NewTestDatabaseFromConn(conn *sql.DB) *Database {
|
||||||
|
return &Database{ //nolint:exhaustruct
|
||||||
|
conn: conn,
|
||||||
|
log: slog.Default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunMigrations applies all schema migrations. Exposed
|
||||||
|
// for integration tests in other packages.
|
||||||
|
func (database *Database) RunMigrations(
|
||||||
|
ctx context.Context,
|
||||||
|
) error {
|
||||||
|
return database.runMigrations(ctx)
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
package globals
|
package globals
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,14 +19,16 @@ var (
|
|||||||
type Globals struct {
|
type Globals struct {
|
||||||
Appname string
|
Appname string
|
||||||
Version string
|
Version string
|
||||||
|
StartTime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Globals instance from the global state.
|
// New creates a new Globals instance from the global state.
|
||||||
func New(_ fx.Lifecycle) (*Globals, error) {
|
func New(_ fx.Lifecycle) (*Globals, error) {
|
||||||
n := &Globals{
|
result := &Globals{
|
||||||
Appname: Appname,
|
Appname: Appname,
|
||||||
Version: Version,
|
Version: Version,
|
||||||
|
StartTime: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return n, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,116 +4,12 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const minPasswordLength = 8
|
const minPasswordLength = 8
|
||||||
|
|
||||||
// HandleRegister creates a new user with a password.
|
|
||||||
func (hdlr *Handlers) HandleRegister() http.HandlerFunc {
|
|
||||||
return func(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
) {
|
|
||||||
request.Body = http.MaxBytesReader(
|
|
||||||
writer, request.Body, hdlr.maxBodySize(),
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.handleRegister(writer, request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hdlr *Handlers) handleRegister(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
) {
|
|
||||||
type registerRequest struct {
|
|
||||||
Nick string `json:"nick"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload registerRequest
|
|
||||||
|
|
||||||
err := json.NewDecoder(request.Body).Decode(&payload)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"invalid request body",
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
payload.Nick = strings.TrimSpace(payload.Nick)
|
|
||||||
|
|
||||||
if !validNickRe.MatchString(payload.Nick) {
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"invalid nick format",
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(payload.Password) < minPasswordLength {
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"password must be at least 8 characters",
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionID, clientID, token, err :=
|
|
||||||
hdlr.params.Database.RegisterUser(
|
|
||||||
request.Context(),
|
|
||||||
payload.Nick,
|
|
||||||
payload.Password,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.handleRegisterError(
|
|
||||||
writer, request, err,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.deliverMOTD(request, clientID, sessionID)
|
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
|
||||||
"id": sessionID,
|
|
||||||
"nick": payload.Nick,
|
|
||||||
"token": token,
|
|
||||||
}, http.StatusCreated)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hdlr *Handlers) handleRegisterError(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
err error,
|
|
||||||
) {
|
|
||||||
if strings.Contains(err.Error(), "UNIQUE") {
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"nick already taken",
|
|
||||||
http.StatusConflict,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.log.Error(
|
|
||||||
"register user failed", "error", err,
|
|
||||||
)
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"internal error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleLogin authenticates a user with nick and password.
|
// HandleLogin authenticates a user with nick and password.
|
||||||
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
|
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
|
||||||
return func(
|
return func(
|
||||||
@@ -132,6 +28,21 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
) {
|
) {
|
||||||
|
ip := clientIP(request)
|
||||||
|
|
||||||
|
if !hdlr.loginLimiter.Allow(ip) {
|
||||||
|
writer.Header().Set(
|
||||||
|
"Retry-After", "1",
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"too many login attempts, try again later",
|
||||||
|
http.StatusTooManyRequests,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
@@ -162,11 +73,27 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionID, _, token, err :=
|
hdlr.executeLogin(
|
||||||
|
writer, request, payload.Nick, payload.Password,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hdlr *Handlers) executeLogin(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
nick, password string,
|
||||||
|
) {
|
||||||
|
remoteIP := clientIP(request)
|
||||||
|
|
||||||
|
hostname := resolveHostname(
|
||||||
|
request.Context(), remoteIP,
|
||||||
|
)
|
||||||
|
|
||||||
|
sessionID, clientID, token, err :=
|
||||||
hdlr.params.Database.LoginUser(
|
hdlr.params.Database.LoginUser(
|
||||||
request.Context(),
|
request.Context(),
|
||||||
payload.Nick,
|
nick, password,
|
||||||
payload.Password,
|
remoteIP, hostname,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondError(
|
hdlr.respondError(
|
||||||
@@ -178,9 +105,78 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hdlr.stats.IncrConnections()
|
||||||
|
|
||||||
|
hdlr.deliverMOTD(
|
||||||
|
request, clientID, sessionID, nick,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initialize channel state so the new client knows
|
||||||
|
// which channels the session already belongs to.
|
||||||
|
hdlr.initChannelState(
|
||||||
|
request, clientID, sessionID, nick,
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.setAuthCookie(writer, request, token)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
"nick": payload.Nick,
|
"nick": nick,
|
||||||
"token": token,
|
|
||||||
}, http.StatusOK)
|
}, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handlePass handles the IRC PASS command to set a
|
||||||
|
// password on the authenticated session, enabling
|
||||||
|
// multi-client login via POST /api/v1/login.
|
||||||
|
func (hdlr *Handlers) handlePass(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
lines := bodyLines()
|
||||||
|
if len(lines) == 0 || lines[0] == "" {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrNeedMoreParams, nick,
|
||||||
|
[]string{irc.CmdPass},
|
||||||
|
"Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
password := lines[0]
|
||||||
|
|
||||||
|
if len(password) < minPasswordLength {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrNeedMoreParams, nick,
|
||||||
|
[]string{irc.CmdPass},
|
||||||
|
"Password must be at least 8 characters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := hdlr.params.Database.SetPassword(
|
||||||
|
request.Context(), sessionID, password,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"set password failed", "error", err,
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package handlers provides HTTP request handlers for the chat server.
|
// Package handlers provides HTTP request handlers for the neoirc server.
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,13 +9,17 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/chat/internal/broker"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/config"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/db"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/healthcheck"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/logger"
|
|
||||||
"go.uber.org/fx"
|
"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/hashcash"
|
||||||
|
"sneak.berlin/go/neoirc/internal/healthcheck"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
"sneak.berlin/go/neoirc/internal/ratelimit"
|
||||||
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
|
"sneak.berlin/go/neoirc/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errUnauthorized = errors.New("unauthorized")
|
var errUnauthorized = errors.New("unauthorized")
|
||||||
@@ -29,9 +33,17 @@ type Params struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
Database *db.Database
|
Database *db.Database
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
|
Stats *stats.Tracker
|
||||||
|
Broker *broker.Broker
|
||||||
|
Service *service.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultIdleTimeout = 24 * time.Hour
|
const defaultIdleTimeout = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
// spentHashcashTTL is how long spent hashcash tokens are
|
||||||
|
// retained for replay prevention. Per issue requirements,
|
||||||
|
// this is 1 year.
|
||||||
|
const spentHashcashTTL = 365 * 24 * time.Hour
|
||||||
|
|
||||||
// Handlers manages HTTP request handling.
|
// Handlers manages HTTP request handling.
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
@@ -39,6 +51,11 @@ type Handlers struct {
|
|||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
broker *broker.Broker
|
broker *broker.Broker
|
||||||
|
svc *service.Service
|
||||||
|
hashcashVal *hashcash.Validator
|
||||||
|
channelHashcash *hashcash.ChannelValidator
|
||||||
|
loginLimiter *ratelimit.Limiter
|
||||||
|
stats *stats.Tracker
|
||||||
cancelCleanup context.CancelFunc
|
cancelCleanup context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,11 +64,31 @@ func New(
|
|||||||
lifecycle fx.Lifecycle,
|
lifecycle fx.Lifecycle,
|
||||||
params Params,
|
params Params,
|
||||||
) (*Handlers, error) {
|
) (*Handlers, error) {
|
||||||
|
resource := params.Config.ServerName
|
||||||
|
if resource == "" {
|
||||||
|
resource = "neoirc"
|
||||||
|
}
|
||||||
|
|
||||||
|
loginRate := params.Config.LoginRateLimit
|
||||||
|
if loginRate <= 0 {
|
||||||
|
loginRate = ratelimit.DefaultRate
|
||||||
|
}
|
||||||
|
|
||||||
|
loginBurst := params.Config.LoginRateBurst
|
||||||
|
if loginBurst <= 0 {
|
||||||
|
loginBurst = ratelimit.DefaultBurst
|
||||||
|
}
|
||||||
|
|
||||||
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
broker: broker.New(),
|
broker: params.Broker,
|
||||||
|
svc: params.Service,
|
||||||
|
hashcashVal: hashcash.NewValidator(resource),
|
||||||
|
channelHashcash: hashcash.NewChannelValidator(),
|
||||||
|
loginLimiter: ratelimit.New(loginRate, loginBurst),
|
||||||
|
stats: params.Stats,
|
||||||
}
|
}
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
@@ -143,6 +180,10 @@ func (hdlr *Handlers) stopCleanup() {
|
|||||||
if hdlr.cancelCleanup != nil {
|
if hdlr.cancelCleanup != nil {
|
||||||
hdlr.cancelCleanup()
|
hdlr.cancelCleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hdlr.loginLimiter != nil {
|
||||||
|
hdlr.loginLimiter.Stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) cleanupLoop(ctx context.Context) {
|
func (hdlr *Handlers) cleanupLoop(ctx context.Context) {
|
||||||
@@ -200,4 +241,93 @@ func (hdlr *Handlers) runCleanup(
|
|||||||
"deleted", deleted,
|
"deleted", deleted,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hdlr.pruneQueuesAndMessages(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDurationConfig parses a Go duration string,
|
||||||
|
// returning zero on empty input and logging on error.
|
||||||
|
func (hdlr *Handlers) parseDurationConfig(
|
||||||
|
name, raw string,
|
||||||
|
) time.Duration {
|
||||||
|
if raw == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
dur, err := time.ParseDuration(raw)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"invalid duration config, skipping",
|
||||||
|
"name", name, "value", raw, "error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return dur
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneQueuesAndMessages removes old client output queue
|
||||||
|
// entries per QUEUE_MAX_AGE and old messages per
|
||||||
|
// MESSAGE_MAX_AGE.
|
||||||
|
func (hdlr *Handlers) pruneQueuesAndMessages(
|
||||||
|
ctx context.Context,
|
||||||
|
) {
|
||||||
|
queueMaxAge := hdlr.parseDurationConfig(
|
||||||
|
"QUEUE_MAX_AGE",
|
||||||
|
hdlr.params.Config.QueueMaxAge,
|
||||||
|
)
|
||||||
|
if queueMaxAge > 0 {
|
||||||
|
queueCutoff := time.Now().Add(-queueMaxAge)
|
||||||
|
|
||||||
|
pruned, err := hdlr.params.Database.
|
||||||
|
PruneOldQueueEntries(ctx, queueCutoff)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"client output queue pruning failed", "error", err,
|
||||||
|
)
|
||||||
|
} else if pruned > 0 {
|
||||||
|
hdlr.log.Info(
|
||||||
|
"pruned old client output queue entries",
|
||||||
|
"deleted", pruned,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messageMaxAge := hdlr.parseDurationConfig(
|
||||||
|
"MESSAGE_MAX_AGE",
|
||||||
|
hdlr.params.Config.MessageMaxAge,
|
||||||
|
)
|
||||||
|
if messageMaxAge > 0 {
|
||||||
|
msgCutoff := time.Now().Add(-messageMaxAge)
|
||||||
|
|
||||||
|
pruned, err := hdlr.params.Database.
|
||||||
|
PruneOldMessages(ctx, msgCutoff)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"message pruning failed", "error", err,
|
||||||
|
)
|
||||||
|
} else if pruned > 0 {
|
||||||
|
hdlr.log.Info(
|
||||||
|
"pruned old messages",
|
||||||
|
"deleted", pruned,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune spent hashcash tokens older than 1 year.
|
||||||
|
hashcashCutoff := time.Now().Add(-spentHashcashTTL)
|
||||||
|
|
||||||
|
pruned, err := hdlr.params.Database.
|
||||||
|
PruneSpentHashcash(ctx, hashcashCutoff)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"spent hashcash pruning failed", "error", err,
|
||||||
|
)
|
||||||
|
} else if pruned > 0 {
|
||||||
|
hdlr.log.Info(
|
||||||
|
"pruned spent hashcash tokens",
|
||||||
|
"deleted", pruned,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ func (hdlr *Handlers) HandleHealthCheck() http.HandlerFunc {
|
|||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
) {
|
) {
|
||||||
resp := hdlr.hc.Healthcheck()
|
resp := hdlr.hc.Healthcheck(request.Context())
|
||||||
hdlr.respondJSON(writer, request, resp, httpStatusOK)
|
hdlr.respondJSON(writer, request, resp, httpStatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
576
internal/handlers/utility.go
Normal file
576
internal/handlers/utility.go
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
1071
internal/handlers/utility_test.go
Normal file
1071
internal/handlers/utility_test.go
Normal file
File diff suppressed because it is too large
Load Diff
186
internal/hashcash/channel.go
Normal file
186
internal/hashcash/channel.go
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
package hashcash
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errBodyHashMismatch = errors.New(
|
||||||
|
"body hash mismatch",
|
||||||
|
)
|
||||||
|
errBodyHashMissing = errors.New(
|
||||||
|
"body hash missing",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChannelValidator checks hashcash stamps for
|
||||||
|
// per-channel PRIVMSG validation. It verifies that
|
||||||
|
// stamps are bound to a specific channel and message
|
||||||
|
// body. Replay prevention is handled externally via
|
||||||
|
// the database spent_hashcash table for persistence
|
||||||
|
// across server restarts (1-year TTL).
|
||||||
|
type ChannelValidator struct{}
|
||||||
|
|
||||||
|
// NewChannelValidator creates a ChannelValidator.
|
||||||
|
func NewChannelValidator() *ChannelValidator {
|
||||||
|
return &ChannelValidator{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BodyHash computes the hex-encoded SHA-256 hash of a
|
||||||
|
// message body for use in hashcash stamp validation.
|
||||||
|
func BodyHash(body []byte) string {
|
||||||
|
hash := sha256.Sum256(body)
|
||||||
|
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateStamp checks a channel hashcash stamp. It
|
||||||
|
// verifies the stamp format, difficulty, date, channel
|
||||||
|
// binding, body hash binding, and proof-of-work. Replay
|
||||||
|
// detection is NOT performed here — callers must check
|
||||||
|
// the spent_hashcash table separately.
|
||||||
|
//
|
||||||
|
// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter.
|
||||||
|
func (cv *ChannelValidator) ValidateStamp(
|
||||||
|
stamp string,
|
||||||
|
requiredBits int,
|
||||||
|
channel string,
|
||||||
|
bodyHash string,
|
||||||
|
) error {
|
||||||
|
if requiredBits <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(stamp, ":")
|
||||||
|
if len(parts) != stampFields {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: expected %d, got %d",
|
||||||
|
errInvalidFields, stampFields, len(parts),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
version := parts[0]
|
||||||
|
bitsStr := parts[1]
|
||||||
|
dateStr := parts[2]
|
||||||
|
resource := parts[3]
|
||||||
|
stampBodyHash := parts[4]
|
||||||
|
|
||||||
|
headerErr := validateChannelHeader(
|
||||||
|
version, bitsStr, resource,
|
||||||
|
requiredBits, channel,
|
||||||
|
)
|
||||||
|
if headerErr != nil {
|
||||||
|
return headerErr
|
||||||
|
}
|
||||||
|
|
||||||
|
stampTime, parseErr := parseStampDate(dateStr)
|
||||||
|
if parseErr != nil {
|
||||||
|
return parseErr
|
||||||
|
}
|
||||||
|
|
||||||
|
timeErr := validateTime(stampTime)
|
||||||
|
if timeErr != nil {
|
||||||
|
return timeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyErr := validateBodyHash(
|
||||||
|
stampBodyHash, bodyHash,
|
||||||
|
)
|
||||||
|
if bodyErr != nil {
|
||||||
|
return bodyErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return validateProof(stamp, requiredBits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StampHash returns a deterministic hash of a stamp
|
||||||
|
// string for use as a spent-token key.
|
||||||
|
func StampHash(stamp string) string {
|
||||||
|
hash := sha256.Sum256([]byte(stamp))
|
||||||
|
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateChannelHeader(
|
||||||
|
version, bitsStr, resource string,
|
||||||
|
requiredBits int,
|
||||||
|
channel string,
|
||||||
|
) error {
|
||||||
|
if version != stampVersion {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: %s", errBadVersion, version,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
claimedBits, err := strconv.Atoi(bitsStr)
|
||||||
|
if err != nil || claimedBits < requiredBits {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: need %d bits",
|
||||||
|
errInsufficientBits, requiredBits,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resource != channel {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: got %q, want %q",
|
||||||
|
errWrongResource, resource, channel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateBodyHash(
|
||||||
|
stampBodyHash, expectedBodyHash string,
|
||||||
|
) error {
|
||||||
|
if stampBodyHash == "" {
|
||||||
|
return errBodyHashMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
if stampBodyHash != expectedBodyHash {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: got %q, want %q",
|
||||||
|
errBodyHashMismatch,
|
||||||
|
stampBodyHash, expectedBodyHash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MintChannelStamp computes a channel hashcash stamp
|
||||||
|
// with the given difficulty, channel name, and body hash.
|
||||||
|
// This is intended for clients to generate stamps before
|
||||||
|
// sending PRIVMSG to hashcash-protected channels.
|
||||||
|
//
|
||||||
|
// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter.
|
||||||
|
func MintChannelStamp(
|
||||||
|
bits int,
|
||||||
|
channel string,
|
||||||
|
bodyHash string,
|
||||||
|
) string {
|
||||||
|
date := time.Now().UTC().Format(dateFormatShort)
|
||||||
|
prefix := fmt.Sprintf(
|
||||||
|
"1:%d:%s:%s:%s:",
|
||||||
|
bits, date, channel, bodyHash,
|
||||||
|
)
|
||||||
|
|
||||||
|
counter := uint64(0)
|
||||||
|
|
||||||
|
for {
|
||||||
|
stamp := prefix + strconv.FormatUint(counter, 16)
|
||||||
|
hash := sha256.Sum256([]byte(stamp))
|
||||||
|
|
||||||
|
if hasLeadingZeroBits(hash[:], bits) {
|
||||||
|
return stamp
|
||||||
|
}
|
||||||
|
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
}
|
||||||
244
internal/hashcash/channel_test.go
Normal file
244
internal/hashcash/channel_test.go
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
package hashcash_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testChannel = "#general"
|
||||||
|
testBodyText = `["hello world"]`
|
||||||
|
)
|
||||||
|
|
||||||
|
func testBodyHash() string {
|
||||||
|
hash := sha256.Sum256([]byte(testBodyText))
|
||||||
|
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelValidateHappyPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewChannelValidator()
|
||||||
|
bodyHash := testBodyHash()
|
||||||
|
|
||||||
|
stamp := hashcash.MintChannelStamp(
|
||||||
|
testBits, testChannel, bodyHash,
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.ValidateStamp(
|
||||||
|
stamp, testBits, testChannel, bodyHash,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid channel stamp rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelValidateWrongChannel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewChannelValidator()
|
||||||
|
bodyHash := testBodyHash()
|
||||||
|
|
||||||
|
stamp := hashcash.MintChannelStamp(
|
||||||
|
testBits, testChannel, bodyHash,
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.ValidateStamp(
|
||||||
|
stamp, testBits, "#other", bodyHash,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected channel mismatch error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelValidateWrongBodyHash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewChannelValidator()
|
||||||
|
bodyHash := testBodyHash()
|
||||||
|
|
||||||
|
stamp := hashcash.MintChannelStamp(
|
||||||
|
testBits, testChannel, bodyHash,
|
||||||
|
)
|
||||||
|
|
||||||
|
wrongHash := sha256.Sum256([]byte("different body"))
|
||||||
|
wrongBodyHash := hex.EncodeToString(wrongHash[:])
|
||||||
|
|
||||||
|
err := validator.ValidateStamp(
|
||||||
|
stamp, testBits, testChannel, wrongBodyHash,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected body hash mismatch error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelValidateInsufficientBits(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewChannelValidator()
|
||||||
|
bodyHash := testBodyHash()
|
||||||
|
|
||||||
|
// Mint with 2 bits but require 4.
|
||||||
|
stamp := hashcash.MintChannelStamp(
|
||||||
|
testBits, testChannel, bodyHash,
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.ValidateStamp(
|
||||||
|
stamp, 4, testChannel, bodyHash,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected insufficient bits error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelValidateZeroBitsSkips(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewChannelValidator()
|
||||||
|
|
||||||
|
err := validator.ValidateStamp(
|
||||||
|
"garbage", 0, "#ch", "abc",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zero bits should skip: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelValidateBadFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewChannelValidator()
|
||||||
|
|
||||||
|
err := validator.ValidateStamp(
|
||||||
|
"not:valid", testBits, testChannel, "abc",
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected bad format error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelValidateBadVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewChannelValidator()
|
||||||
|
bodyHash := testBodyHash()
|
||||||
|
|
||||||
|
stamp := "2:2:260317:#general:" + bodyHash + ":counter"
|
||||||
|
|
||||||
|
err := validator.ValidateStamp(
|
||||||
|
stamp, testBits, testChannel, bodyHash,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected bad version error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelValidateExpiredStamp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewChannelValidator()
|
||||||
|
bodyHash := testBodyHash()
|
||||||
|
|
||||||
|
// Mint with a very old date by manually constructing.
|
||||||
|
stamp := mintStampWithDate(
|
||||||
|
t, testBits, testChannel, "200101",
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.ValidateStamp(
|
||||||
|
stamp, testBits, testChannel, bodyHash,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected expired stamp error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelValidateMissingBodyHash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewChannelValidator()
|
||||||
|
bodyHash := testBodyHash()
|
||||||
|
|
||||||
|
// Construct a stamp with empty body hash field.
|
||||||
|
stamp := mintStampWithDate(
|
||||||
|
t, testBits, testChannel, todayDate(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// This uses the session-style stamp which has empty
|
||||||
|
// ext field — body hash is missing.
|
||||||
|
err := validator.ValidateStamp(
|
||||||
|
stamp, testBits, testChannel, bodyHash,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected missing body hash error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBodyHash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
body := []byte(`["hello world"]`)
|
||||||
|
bodyHash := hashcash.BodyHash(body)
|
||||||
|
|
||||||
|
if len(bodyHash) != 64 {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 64-char hex hash, got %d",
|
||||||
|
len(bodyHash),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same input should produce same hash.
|
||||||
|
bodyHash2 := hashcash.BodyHash(body)
|
||||||
|
if bodyHash != bodyHash2 {
|
||||||
|
t.Fatal("body hash not deterministic")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different input should produce different hash.
|
||||||
|
bodyHash3 := hashcash.BodyHash([]byte("different"))
|
||||||
|
if bodyHash == bodyHash3 {
|
||||||
|
t.Fatal("different inputs produced same hash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStampHash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
hash1 := hashcash.StampHash("stamp1")
|
||||||
|
hash2 := hashcash.StampHash("stamp2")
|
||||||
|
|
||||||
|
if hash1 == hash2 {
|
||||||
|
t.Fatal("different stamps produced same hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same input should be deterministic.
|
||||||
|
hash1b := hashcash.StampHash("stamp1")
|
||||||
|
if hash1 != hash1b {
|
||||||
|
t.Fatal("stamp hash not deterministic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMintChannelStamp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
bodyHash := testBodyHash()
|
||||||
|
stamp := hashcash.MintChannelStamp(
|
||||||
|
testBits, testChannel, bodyHash,
|
||||||
|
)
|
||||||
|
|
||||||
|
if stamp == "" {
|
||||||
|
t.Fatal("expected non-empty stamp")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the minted stamp.
|
||||||
|
validator := hashcash.NewChannelValidator()
|
||||||
|
|
||||||
|
err := validator.ValidateStamp(
|
||||||
|
stamp, testBits, testChannel, bodyHash,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minted stamp failed validation: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
277
internal/hashcash/hashcash.go
Normal file
277
internal/hashcash/hashcash.go
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
// Package hashcash implements SHA-256-based hashcash
|
||||||
|
// proof-of-work validation for abuse prevention.
|
||||||
|
//
|
||||||
|
// Stamp format: 1:bits:YYMMDD:resource::counter.
|
||||||
|
//
|
||||||
|
// The SHA-256 hash of the entire stamp string must have
|
||||||
|
// at least `bits` leading zero bits.
|
||||||
|
package hashcash
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// stampVersion is the only supported hashcash version.
|
||||||
|
stampVersion = "1"
|
||||||
|
// stampFields is the number of fields in a stamp.
|
||||||
|
stampFields = 6
|
||||||
|
// maxStampAge is how old a stamp can be before
|
||||||
|
// rejection.
|
||||||
|
maxStampAge = 48 * time.Hour
|
||||||
|
// maxFutureSkew allows stamps slightly in the future.
|
||||||
|
maxFutureSkew = 1 * time.Hour
|
||||||
|
// pruneInterval controls how often expired stamps are
|
||||||
|
// removed from the spent set.
|
||||||
|
pruneInterval = 10 * time.Minute
|
||||||
|
// dateFormatShort is the YYMMDD date layout.
|
||||||
|
dateFormatShort = "060102"
|
||||||
|
// dateFormatLong is the YYMMDDHHMMSS date layout.
|
||||||
|
dateFormatLong = "060102150405"
|
||||||
|
// dateShortLen is the length of YYMMDD.
|
||||||
|
dateShortLen = 6
|
||||||
|
// dateLongLen is the length of YYMMDDHHMMSS.
|
||||||
|
dateLongLen = 12
|
||||||
|
// bitsPerByte is the number of bits in a byte.
|
||||||
|
bitsPerByte = 8
|
||||||
|
// fullByteMask is 0xFF, a mask for all bits in a byte.
|
||||||
|
fullByteMask = 0xFF
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errInvalidFields = errors.New("invalid stamp field count")
|
||||||
|
errBadVersion = errors.New("unsupported stamp version")
|
||||||
|
errInsufficientBits = errors.New("insufficient difficulty")
|
||||||
|
errWrongResource = errors.New("wrong resource")
|
||||||
|
errStampExpired = errors.New("stamp expired")
|
||||||
|
errStampFuture = errors.New("stamp date in future")
|
||||||
|
errProofFailed = errors.New("proof-of-work failed")
|
||||||
|
errStampReused = errors.New("stamp already used")
|
||||||
|
errBadDateFormat = errors.New("unrecognized date format")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validator checks hashcash stamps for validity and
|
||||||
|
// prevents replay attacks via an in-memory spent set.
|
||||||
|
type Validator struct {
|
||||||
|
resource string
|
||||||
|
mu sync.Mutex
|
||||||
|
spent map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewValidator creates a Validator for the given resource.
|
||||||
|
func NewValidator(resource string) *Validator {
|
||||||
|
validator := &Validator{
|
||||||
|
resource: resource,
|
||||||
|
mu: sync.Mutex{},
|
||||||
|
spent: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
|
||||||
|
go validator.pruneLoop()
|
||||||
|
|
||||||
|
return validator
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks a hashcash stamp. It returns nil if the
|
||||||
|
// stamp is valid and has not been seen before.
|
||||||
|
func (v *Validator) Validate(
|
||||||
|
stamp string,
|
||||||
|
requiredBits int,
|
||||||
|
) error {
|
||||||
|
if requiredBits <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(stamp, ":")
|
||||||
|
if len(parts) != stampFields {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: expected %d, got %d",
|
||||||
|
errInvalidFields, stampFields, len(parts),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
version := parts[0]
|
||||||
|
bitsStr := parts[1]
|
||||||
|
dateStr := parts[2]
|
||||||
|
resource := parts[3]
|
||||||
|
|
||||||
|
if err := v.validateHeader(
|
||||||
|
version, bitsStr, resource, requiredBits,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stampTime, err := parseStampDate(dateStr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateTime(stampTime); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateProof(
|
||||||
|
stamp, requiredBits,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return v.checkAndRecordStamp(stamp, stampTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Validator) validateHeader(
|
||||||
|
version, bitsStr, resource string,
|
||||||
|
requiredBits int,
|
||||||
|
) error {
|
||||||
|
if version != stampVersion {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: %s", errBadVersion, version,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
claimedBits, err := strconv.Atoi(bitsStr)
|
||||||
|
if err != nil || claimedBits < requiredBits {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: need %d bits",
|
||||||
|
errInsufficientBits, requiredBits,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resource != v.resource {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: got %q, want %q",
|
||||||
|
errWrongResource, resource, v.resource,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTime(stampTime time.Time) error {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if now.Sub(stampTime) > maxStampAge {
|
||||||
|
return errStampExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
if stampTime.Sub(now) > maxFutureSkew {
|
||||||
|
return errStampFuture
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateProof(stamp string, requiredBits int) error {
|
||||||
|
hash := sha256.Sum256([]byte(stamp))
|
||||||
|
if !hasLeadingZeroBits(hash[:], requiredBits) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: need %d leading zero bits",
|
||||||
|
errProofFailed, requiredBits,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Validator) checkAndRecordStamp(
|
||||||
|
stamp string,
|
||||||
|
stampTime time.Time,
|
||||||
|
) error {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
|
||||||
|
if _, ok := v.spent[stamp]; ok {
|
||||||
|
return errStampReused
|
||||||
|
}
|
||||||
|
|
||||||
|
v.spent[stamp] = stampTime
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasLeadingZeroBits checks if the hash has at least n
|
||||||
|
// leading zero bits.
|
||||||
|
func hasLeadingZeroBits(hash []byte, numBits int) bool {
|
||||||
|
fullBytes := numBits / bitsPerByte
|
||||||
|
remainBits := numBits % bitsPerByte
|
||||||
|
|
||||||
|
for idx := range fullBytes {
|
||||||
|
if hash[idx] != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if remainBits > 0 && fullBytes < len(hash) {
|
||||||
|
mask := byte(
|
||||||
|
fullByteMask << (bitsPerByte - remainBits),
|
||||||
|
)
|
||||||
|
|
||||||
|
if hash[fullBytes]&mask != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStampDate parses a hashcash date stamp.
|
||||||
|
// Supports YYMMDD and YYMMDDHHMMSS formats.
|
||||||
|
func parseStampDate(dateStr string) (time.Time, error) {
|
||||||
|
switch len(dateStr) {
|
||||||
|
case dateShortLen:
|
||||||
|
parsed, err := time.Parse(
|
||||||
|
dateFormatShort, dateStr,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf(
|
||||||
|
"parse date: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed, nil
|
||||||
|
case dateLongLen:
|
||||||
|
parsed, err := time.Parse(
|
||||||
|
dateFormatLong, dateStr,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf(
|
||||||
|
"parse date: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed, nil
|
||||||
|
default:
|
||||||
|
return time.Time{}, fmt.Errorf(
|
||||||
|
"%w: %q", errBadDateFormat, dateStr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneLoop periodically removes expired stamps from the
|
||||||
|
// spent set.
|
||||||
|
func (v *Validator) pruneLoop() {
|
||||||
|
ticker := time.NewTicker(pruneInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
v.prune()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Validator) prune() {
|
||||||
|
cutoff := time.Now().Add(-maxStampAge)
|
||||||
|
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
|
||||||
|
for stamp, stampTime := range v.spent {
|
||||||
|
if stampTime.Before(cutoff) {
|
||||||
|
delete(v.spent, stamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
261
internal/hashcash/hashcash_test.go
Normal file
261
internal/hashcash/hashcash_test.go
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
package hashcash_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testBits = 2
|
||||||
|
|
||||||
|
// mintStampWithDate creates a valid hashcash stamp using
|
||||||
|
// the given date string.
|
||||||
|
func mintStampWithDate(
|
||||||
|
tb testing.TB,
|
||||||
|
bits int,
|
||||||
|
resource string,
|
||||||
|
date string,
|
||||||
|
) string {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
prefix := fmt.Sprintf(
|
||||||
|
"1:%d:%s:%s::", bits, date, resource,
|
||||||
|
)
|
||||||
|
|
||||||
|
for {
|
||||||
|
counterVal, err := rand.Int(
|
||||||
|
rand.Reader, big.NewInt(1<<48),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("random counter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stamp := prefix + hex.EncodeToString(
|
||||||
|
counterVal.Bytes(),
|
||||||
|
)
|
||||||
|
hash := sha256.Sum256([]byte(stamp))
|
||||||
|
|
||||||
|
if hasLeadingZeroBits(hash[:], bits) {
|
||||||
|
return stamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasLeadingZeroBits checks if hash has at least numBits
|
||||||
|
// leading zero bits. Duplicated here for test minting.
|
||||||
|
func hasLeadingZeroBits(
|
||||||
|
hash []byte,
|
||||||
|
numBits int,
|
||||||
|
) bool {
|
||||||
|
fullBytes := numBits / 8
|
||||||
|
remainBits := numBits % 8
|
||||||
|
|
||||||
|
for idx := range fullBytes {
|
||||||
|
if hash[idx] != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if remainBits > 0 && fullBytes < len(hash) {
|
||||||
|
mask := byte(0xFF << (8 - remainBits))
|
||||||
|
|
||||||
|
if hash[fullBytes]&mask != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func todayDate() string {
|
||||||
|
return time.Now().UTC().Format("060102")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMintAndValidate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
stamp := mintStampWithDate(
|
||||||
|
t, testBits, "test-resource", todayDate(),
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.Validate(stamp, testBits)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid stamp rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplayDetection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
stamp := mintStampWithDate(
|
||||||
|
t, testBits, "test-resource", todayDate(),
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.Validate(stamp, testBits)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first use failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = validator.Validate(stamp, testBits)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("replay not detected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResourceMismatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("correct-resource")
|
||||||
|
stamp := mintStampWithDate(
|
||||||
|
t, testBits, "wrong-resource", todayDate(),
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.Validate(stamp, testBits)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected resource mismatch error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidStampFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
|
||||||
|
err := validator.Validate(
|
||||||
|
"not:a:valid:stamp", testBits,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for bad format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBadVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
stamp := fmt.Sprintf(
|
||||||
|
"2:%d:%s:%s::abc123",
|
||||||
|
testBits, todayDate(), "test-resource",
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.Validate(stamp, testBits)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected bad version error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInsufficientDifficulty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
// Claimed bits=1, but we require testBits=2.
|
||||||
|
stamp := fmt.Sprintf(
|
||||||
|
"1:1:%s:%s::counter",
|
||||||
|
todayDate(), "test-resource",
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.Validate(stamp, testBits)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected insufficient bits error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiredStamp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
oldDate := time.Now().Add(-72 * time.Hour).
|
||||||
|
UTC().Format("060102")
|
||||||
|
stamp := mintStampWithDate(
|
||||||
|
t, testBits, "test-resource", oldDate,
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.Validate(stamp, testBits)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected expired stamp error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZeroBitsSkipsValidation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
|
||||||
|
err := validator.Validate("garbage", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zero bits should skip: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLongDateFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
longDate := time.Now().UTC().Format("060102150405")
|
||||||
|
stamp := mintStampWithDate(
|
||||||
|
t, testBits, "test-resource", longDate,
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.Validate(stamp, testBits)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("long date stamp rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBadDateFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
stamp := fmt.Sprintf(
|
||||||
|
"1:%d:BADDATE:%s::counter",
|
||||||
|
testBits, "test-resource",
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.Validate(stamp, testBits)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected bad date error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipleUniqueStamps(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
|
||||||
|
for range 5 {
|
||||||
|
stamp := mintStampWithDate(
|
||||||
|
t, testBits, "test-resource", todayDate(),
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.Validate(stamp, testBits)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unique stamp rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHigherBitsStillValid(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Mint with bits=4 but validate requiring only 2.
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
stamp := mintStampWithDate(
|
||||||
|
t, 4, "test-resource", todayDate(),
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.Validate(stamp, testBits)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"higher-difficulty stamp rejected: %v",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,12 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/chat/internal/config"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/db"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/logger"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"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/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params defines the dependencies for creating a Healthcheck.
|
// Params defines the dependencies for creating a Healthcheck.
|
||||||
@@ -21,6 +22,7 @@ type Params struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Database *db.Database
|
Database *db.Database
|
||||||
|
Stats *stats.Tracker
|
||||||
}
|
}
|
||||||
|
|
||||||
// Healthcheck tracks server uptime and provides health status.
|
// Healthcheck tracks server uptime and provides health status.
|
||||||
@@ -64,11 +66,22 @@ type Response struct {
|
|||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Appname string `json:"appname"`
|
Appname string `json:"appname"`
|
||||||
Maintenance bool `json:"maintenanceMode"`
|
Maintenance bool `json:"maintenanceMode"`
|
||||||
|
|
||||||
|
// Runtime statistics.
|
||||||
|
Sessions int64 `json:"sessions"`
|
||||||
|
Clients int64 `json:"clients"`
|
||||||
|
QueuedLines int64 `json:"queuedLines"`
|
||||||
|
Channels int64 `json:"channels"`
|
||||||
|
ConnectionsSinceBoot int64 `json:"connectionsSinceBoot"`
|
||||||
|
SessionsSinceBoot int64 `json:"sessionsSinceBoot"`
|
||||||
|
MessagesSinceBoot int64 `json:"messagesSinceBoot"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Healthcheck returns the current health status of the server.
|
// Healthcheck returns the current health status of the server.
|
||||||
func (hcheck *Healthcheck) Healthcheck() *Response {
|
func (hcheck *Healthcheck) Healthcheck(
|
||||||
return &Response{
|
ctx context.Context,
|
||||||
|
) *Response {
|
||||||
|
resp := &Response{
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
UptimeSeconds: int64(hcheck.uptime().Seconds()),
|
UptimeSeconds: int64(hcheck.uptime().Seconds()),
|
||||||
@@ -76,6 +89,64 @@ func (hcheck *Healthcheck) Healthcheck() *Response {
|
|||||||
Appname: hcheck.params.Globals.Appname,
|
Appname: hcheck.params.Globals.Appname,
|
||||||
Version: hcheck.params.Globals.Version,
|
Version: hcheck.params.Globals.Version,
|
||||||
Maintenance: hcheck.params.Config.MaintenanceMode,
|
Maintenance: hcheck.params.Config.MaintenanceMode,
|
||||||
|
|
||||||
|
Sessions: 0,
|
||||||
|
Clients: 0,
|
||||||
|
QueuedLines: 0,
|
||||||
|
Channels: 0,
|
||||||
|
ConnectionsSinceBoot: hcheck.params.Stats.ConnectionsSinceBoot(),
|
||||||
|
SessionsSinceBoot: hcheck.params.Stats.SessionsSinceBoot(),
|
||||||
|
MessagesSinceBoot: hcheck.params.Stats.MessagesSinceBoot(),
|
||||||
|
}
|
||||||
|
|
||||||
|
hcheck.populateDBStats(ctx, resp)
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// populateDBStats fills in database-derived counters.
|
||||||
|
func (hcheck *Healthcheck) populateDBStats(
|
||||||
|
ctx context.Context,
|
||||||
|
resp *Response,
|
||||||
|
) {
|
||||||
|
sessions, err := hcheck.params.Database.GetUserCount(ctx)
|
||||||
|
if err != nil {
|
||||||
|
hcheck.log.Error(
|
||||||
|
"healthcheck: session count failed",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
resp.Sessions = sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
clients, err := hcheck.params.Database.GetClientCount(ctx)
|
||||||
|
if err != nil {
|
||||||
|
hcheck.log.Error(
|
||||||
|
"healthcheck: client count failed",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
resp.Clients = clients
|
||||||
|
}
|
||||||
|
|
||||||
|
queued, err := hcheck.params.Database.GetQueueEntryCount(ctx)
|
||||||
|
if err != nil {
|
||||||
|
hcheck.log.Error(
|
||||||
|
"healthcheck: queue entry count failed",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
resp.QueuedLines = queued
|
||||||
|
}
|
||||||
|
|
||||||
|
channels, err := hcheck.params.Database.GetChannelCount(ctx)
|
||||||
|
if err != nil {
|
||||||
|
hcheck.log.Error(
|
||||||
|
"healthcheck: channel count failed",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
resp.Channels = channels
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1524
internal/ircserver/commands.go
Normal file
1524
internal/ircserver/commands.go
Normal file
File diff suppressed because it is too large
Load Diff
634
internal/ircserver/conn.go
Normal file
634
internal/ircserver/conn.go
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
package ircserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
pongDeadline = 30 * time.Second
|
||||||
|
maxNickLen = 32
|
||||||
|
minPasswordLen = 8
|
||||||
|
maxHashcashBits = 40
|
||||||
|
)
|
||||||
|
|
||||||
|
// cmdHandler is the signature for registered IRC command
|
||||||
|
// handlers.
|
||||||
|
type cmdHandler func(ctx context.Context, msg *Message)
|
||||||
|
|
||||||
|
// Conn represents a single IRC client TCP connection.
|
||||||
|
type Conn struct {
|
||||||
|
conn net.Conn
|
||||||
|
log *slog.Logger
|
||||||
|
database *db.Database
|
||||||
|
brk *broker.Broker
|
||||||
|
cfg *config.Config
|
||||||
|
svc *service.Service
|
||||||
|
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
|
||||||
|
realname string
|
||||||
|
hostname string
|
||||||
|
remoteIP string
|
||||||
|
sessionID int64
|
||||||
|
clientID int64
|
||||||
|
|
||||||
|
registered bool
|
||||||
|
gotNick bool
|
||||||
|
gotUser bool
|
||||||
|
passWord string
|
||||||
|
|
||||||
|
lastQueueID int64
|
||||||
|
closed bool
|
||||||
|
killed bool
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConn(
|
||||||
|
ctx context.Context,
|
||||||
|
tcpConn net.Conn,
|
||||||
|
log *slog.Logger,
|
||||||
|
database *db.Database,
|
||||||
|
brk *broker.Broker,
|
||||||
|
cfg *config.Config,
|
||||||
|
svc *service.Service,
|
||||||
|
) *Conn {
|
||||||
|
host, _, _ := net.SplitHostPort(tcpConn.RemoteAddr().String())
|
||||||
|
|
||||||
|
srvName := cfg.ServerName
|
||||||
|
if srvName == "" {
|
||||||
|
srvName = "neoirc"
|
||||||
|
}
|
||||||
|
|
||||||
|
conn := &Conn{ //nolint:exhaustruct // zero-value defaults
|
||||||
|
conn: tcpConn,
|
||||||
|
log: log,
|
||||||
|
database: database,
|
||||||
|
brk: brk,
|
||||||
|
cfg: cfg,
|
||||||
|
svc: svc,
|
||||||
|
serverSfx: srvName,
|
||||||
|
remoteIP: host,
|
||||||
|
hostname: resolveHost(ctx, host),
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.commands = conn.buildCommandMap()
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return map[string]cmdHandler{
|
||||||
|
irc.CmdPing: func(_ context.Context, msg *Message) {
|
||||||
|
c.handlePing(msg)
|
||||||
|
},
|
||||||
|
"PONG": func(context.Context, *Message) {},
|
||||||
|
irc.CmdNick: c.handleNick,
|
||||||
|
irc.CmdPrivmsg: c.handlePrivmsg,
|
||||||
|
irc.CmdNotice: c.handlePrivmsg,
|
||||||
|
irc.CmdJoin: c.handleJoin,
|
||||||
|
irc.CmdPart: c.handlePart,
|
||||||
|
irc.CmdQuit: func(_ context.Context, msg *Message) {
|
||||||
|
c.handleQuit(msg)
|
||||||
|
},
|
||||||
|
irc.CmdTopic: c.handleTopic,
|
||||||
|
irc.CmdMode: c.handleMode,
|
||||||
|
irc.CmdNames: c.handleNames,
|
||||||
|
irc.CmdList: func(ctx context.Context, _ *Message) { c.handleList(ctx) },
|
||||||
|
irc.CmdWhois: c.handleWhois,
|
||||||
|
irc.CmdWho: c.handleWho,
|
||||||
|
irc.CmdLusers: func(ctx context.Context, _ *Message) { c.handleLusers(ctx) },
|
||||||
|
irc.CmdMotd: func(context.Context, *Message) { c.deliverMOTD() },
|
||||||
|
irc.CmdOper: c.handleOper,
|
||||||
|
irc.CmdAway: c.handleAway,
|
||||||
|
irc.CmdKick: c.handleKick,
|
||||||
|
irc.CmdPass: c.handlePassPostReg,
|
||||||
|
"INVITE": c.handleInvite,
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveHost does a reverse DNS lookup, returning the IP
|
||||||
|
// on failure.
|
||||||
|
func resolveHost(ctx context.Context, addr string) string {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, dnsTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resolver := &net.Resolver{} //nolint:exhaustruct
|
||||||
|
|
||||||
|
names, err := resolver.LookupAddr(ctx, addr)
|
||||||
|
if err != nil || len(names) == 0 {
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSuffix(names[0], ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve is the main loop for a single IRC client connection.
|
||||||
|
func (c *Conn) serve(ctx context.Context) {
|
||||||
|
ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
defer c.cleanup(ctx)
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(c.conn)
|
||||||
|
scanner.Buffer(make([]byte, maxLineLen), maxLineLen)
|
||||||
|
|
||||||
|
for {
|
||||||
|
_ = c.conn.SetReadDeadline(
|
||||||
|
time.Now().Add(readTimeout),
|
||||||
|
)
|
||||||
|
|
||||||
|
if !scanner.Scan() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
line := scanner.Text()
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := ParseMessage(line)
|
||||||
|
if msg == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
c.handleMessage(ctx, msg)
|
||||||
|
|
||||||
|
if c.isClosed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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),
|
||||||
|
)
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintf(c.conn, "%s\r\n", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendNumeric sends a numeric reply from the server.
|
||||||
|
func (c *Conn) sendNumeric(
|
||||||
|
code irc.IRCMessageType,
|
||||||
|
params ...string,
|
||||||
|
) {
|
||||||
|
nick := c.nick
|
||||||
|
if nick == "" {
|
||||||
|
nick = "*"
|
||||||
|
}
|
||||||
|
|
||||||
|
allParams := make([]string, 0, 1+len(params))
|
||||||
|
allParams = append(allParams, nick)
|
||||||
|
allParams = append(allParams, params...)
|
||||||
|
|
||||||
|
c.send(FormatMessage(
|
||||||
|
c.serverSfx, code.Code(), allParams...,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendFromServer sends a message from the server.
|
||||||
|
func (c *Conn) sendFromServer(
|
||||||
|
command string, params ...string,
|
||||||
|
) {
|
||||||
|
c.send(FormatMessage(c.serverSfx, command, params...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostmask returns the client's full hostmask
|
||||||
|
// (nick!user@host).
|
||||||
|
func (c *Conn) hostmask() string {
|
||||||
|
user := c.username
|
||||||
|
if user == "" {
|
||||||
|
user = c.nick
|
||||||
|
}
|
||||||
|
|
||||||
|
host := c.hostname
|
||||||
|
if host == "" {
|
||||||
|
host = c.remoteIP
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.nick + "!" + user + "@" + host
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMessage dispatches a parsed IRC message using
|
||||||
|
// the command handler map.
|
||||||
|
func (c *Conn) handleMessage(
|
||||||
|
ctx context.Context,
|
||||||
|
msg *Message,
|
||||||
|
) {
|
||||||
|
// Before registration, only NICK, USER, PASS, PING,
|
||||||
|
// QUIT, and CAP are accepted.
|
||||||
|
if !c.registered {
|
||||||
|
c.handlePreRegistration(ctx, msg)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler, ok := c.commands[msg.Command]
|
||||||
|
if !ok {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrUnknownCommand,
|
||||||
|
msg.Command, "Unknown command",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler(ctx, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePreRegistration handles messages before the
|
||||||
|
// connection is registered (NICK+USER received).
|
||||||
|
func (c *Conn) handlePreRegistration(
|
||||||
|
ctx context.Context,
|
||||||
|
msg *Message,
|
||||||
|
) {
|
||||||
|
switch msg.Command {
|
||||||
|
case irc.CmdPass:
|
||||||
|
if len(msg.Params) < 1 {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNeedMoreParams,
|
||||||
|
"PASS", "Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.passWord = msg.Params[0]
|
||||||
|
case irc.CmdNick:
|
||||||
|
if len(msg.Params) < 1 {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNoNicknameGiven,
|
||||||
|
"No nickname given",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.nick = msg.Params[0]
|
||||||
|
if len(c.nick) > maxNickLen {
|
||||||
|
c.nick = c.nick[:maxNickLen]
|
||||||
|
}
|
||||||
|
|
||||||
|
c.gotNick = true
|
||||||
|
case irc.CmdUser:
|
||||||
|
if len(msg.Params) < 4 { //nolint:mnd
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNeedMoreParams,
|
||||||
|
"USER", "Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.username = msg.Params[0]
|
||||||
|
c.realname = msg.Params[3]
|
||||||
|
c.gotUser = true
|
||||||
|
case irc.CmdPing:
|
||||||
|
c.handlePing(msg)
|
||||||
|
|
||||||
|
return
|
||||||
|
case irc.CmdQuit:
|
||||||
|
c.handleQuit(msg)
|
||||||
|
|
||||||
|
return
|
||||||
|
case "CAP":
|
||||||
|
c.handleCAP(msg)
|
||||||
|
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNotRegistered,
|
||||||
|
"You have not registered",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to complete registration once we have both
|
||||||
|
// NICK and USER.
|
||||||
|
if c.gotNick && c.gotUser {
|
||||||
|
c.completeRegistration(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// completeRegistration creates a session and sends the
|
||||||
|
// welcome burst.
|
||||||
|
func (c *Conn) completeRegistration(ctx context.Context) {
|
||||||
|
// Check if nick is valid.
|
||||||
|
if c.nick == "" {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNoNicknameGiven, "No nickname given",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session in DB.
|
||||||
|
sessionID, clientID, _, err := c.database.CreateSession(
|
||||||
|
ctx, c.nick, c.username, c.hostname, c.remoteIP,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "UNIQUE constraint") ||
|
||||||
|
strings.Contains(err.Error(), "nick") {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNicknameInUse,
|
||||||
|
c.nick, "Nickname is already in use",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Error(
|
||||||
|
"failed to create session", "error", err,
|
||||||
|
)
|
||||||
|
c.send("ERROR :Internal server error")
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.closed = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.sessionID = sessionID
|
||||||
|
c.clientID = clientID
|
||||||
|
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 {
|
||||||
|
c.setPassword(ctx, c.passWord)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send welcome burst.
|
||||||
|
c.deliverWelcome()
|
||||||
|
c.deliverLusers(ctx)
|
||||||
|
c.deliverMOTD()
|
||||||
|
|
||||||
|
// Start the message relay goroutine.
|
||||||
|
go c.relayMessages(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverWelcome sends 001-005 welcome numerics.
|
||||||
|
func (c *Conn) deliverWelcome() {
|
||||||
|
c.sendNumeric(irc.RplWelcome, fmt.Sprintf(
|
||||||
|
"Welcome to the %s Network, %s",
|
||||||
|
c.serverSfx, c.hostmask(),
|
||||||
|
))
|
||||||
|
c.sendNumeric(irc.RplYourHost, fmt.Sprintf(
|
||||||
|
"Your host is %s, running version neoirc",
|
||||||
|
c.serverSfx,
|
||||||
|
))
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplCreated,
|
||||||
|
"This server was created recently",
|
||||||
|
)
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplMyInfo,
|
||||||
|
c.serverSfx, "neoirc", "", "mnst",
|
||||||
|
)
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplIsupport,
|
||||||
|
"CHANTYPES=#",
|
||||||
|
"NICKLEN=32",
|
||||||
|
"PREFIX=(ov)@+",
|
||||||
|
"CHANMODES=,,H,imnst",
|
||||||
|
"NETWORK="+c.serverSfx,
|
||||||
|
"are supported by this server",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverLusers sends 251/252/254/255 server statistics.
|
||||||
|
func (c *Conn) deliverLusers(ctx context.Context) {
|
||||||
|
users, _ := c.database.GetUserCount(ctx)
|
||||||
|
opers, _ := c.database.GetOperCount(ctx)
|
||||||
|
channels, _ := c.database.GetChannelCount(ctx)
|
||||||
|
|
||||||
|
c.sendNumeric(irc.RplLuserClient, fmt.Sprintf(
|
||||||
|
"There are %d users and 0 invisible on 1 servers",
|
||||||
|
users,
|
||||||
|
))
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplLuserOp,
|
||||||
|
strconv.FormatInt(opers, 10),
|
||||||
|
"operator(s) online",
|
||||||
|
)
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplLuserChannels,
|
||||||
|
strconv.FormatInt(channels, 10),
|
||||||
|
"channels formed",
|
||||||
|
)
|
||||||
|
c.sendNumeric(irc.RplLuserMe, fmt.Sprintf(
|
||||||
|
"I have %d clients and 1 servers", users,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverMOTD sends 375/372/376 MOTD lines.
|
||||||
|
func (c *Conn) deliverMOTD() {
|
||||||
|
motd := c.cfg.MOTD
|
||||||
|
if motd == "" {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNoMotd, "MOTD File is missing",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.sendNumeric(irc.RplMotdStart, fmt.Sprintf(
|
||||||
|
"- %s Message of the Day -", c.serverSfx,
|
||||||
|
))
|
||||||
|
|
||||||
|
for _, line := range strings.Split(motd, "\n") {
|
||||||
|
c.sendNumeric(irc.RplMotd, "- "+line)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplEndOfMotd, "End of /MOTD command",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setPassword sets a bcrypt password on the session.
|
||||||
|
func (c *Conn) setPassword(ctx context.Context, pw string) {
|
||||||
|
// Use the database's auth module to hash and store.
|
||||||
|
err := c.database.SetPassword(ctx, c.sessionID, pw)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Error(
|
||||||
|
"failed to set password", "error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
128
internal/ircserver/conn_test.go
Normal file
128
internal/ircserver/conn_test.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
75
internal/ircserver/export_test.go
Normal file
75
internal/ircserver/export_test.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package ircserver
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewTestServer creates a Server suitable for testing.
|
||||||
|
// The caller must call Stop() when finished.
|
||||||
|
func NewTestServer(
|
||||||
|
log *slog.Logger,
|
||||||
|
cfg *config.Config,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
return &Server{ //nolint:exhaustruct
|
||||||
|
log: log,
|
||||||
|
cfg: cfg,
|
||||||
|
database: database,
|
||||||
|
brk: brk,
|
||||||
|
svc: svc,
|
||||||
|
conns: make(map[*Conn]struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start exposes the unexported start method for tests.
|
||||||
|
func (s *Server) Start(addr string) error {
|
||||||
|
return s.start(context.Background(), addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop exposes the unexported stop method for tests.
|
||||||
|
func (s *Server) Stop() {
|
||||||
|
s.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listener returns the server's net.Listener for tests.
|
||||||
|
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
|
||||||
|
}
|
||||||
1320
internal/ircserver/integration_test.go
Normal file
1320
internal/ircserver/integration_test.go
Normal file
File diff suppressed because it is too large
Load Diff
123
internal/ircserver/parser.go
Normal file
123
internal/ircserver/parser.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// Package ircserver implements a traditional IRC wire protocol
|
||||||
|
// listener (RFC 1459/2812) that bridges to the neoirc HTTP/JSON
|
||||||
|
// server internals.
|
||||||
|
package ircserver
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// Message represents a parsed IRC wire protocol message.
|
||||||
|
type Message struct {
|
||||||
|
// Prefix is the optional :prefix at the start (may be
|
||||||
|
// empty for client-to-server messages).
|
||||||
|
Prefix string
|
||||||
|
// Command is the IRC command (e.g., "PRIVMSG", "NICK").
|
||||||
|
Command string
|
||||||
|
// Params holds the positional parameters, including the
|
||||||
|
// trailing parameter (which was preceded by ':' on the
|
||||||
|
// wire).
|
||||||
|
Params []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseMessage parses a single IRC wire protocol line
|
||||||
|
// (without the trailing CR-LF) into a Message.
|
||||||
|
// Returns nil if the line is empty.
|
||||||
|
//
|
||||||
|
// IRC message format (RFC 1459 §2.3.1):
|
||||||
|
//
|
||||||
|
// [":" prefix SPACE] command { SPACE param } [SPACE ":" trailing]
|
||||||
|
func ParseMessage(line string) *Message {
|
||||||
|
if line == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := &Message{} //nolint:exhaustruct // fields set below
|
||||||
|
|
||||||
|
// Extract prefix if present.
|
||||||
|
if line[0] == ':' {
|
||||||
|
idx := strings.IndexByte(line, ' ')
|
||||||
|
if idx < 0 {
|
||||||
|
// Only a prefix, no command — invalid.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.Prefix = line[1:idx]
|
||||||
|
line = line[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip leading spaces.
|
||||||
|
line = strings.TrimLeft(line, " ")
|
||||||
|
if line == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract command.
|
||||||
|
idx := strings.IndexByte(line, ' ')
|
||||||
|
if idx < 0 {
|
||||||
|
msg.Command = strings.ToUpper(line)
|
||||||
|
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.Command = strings.ToUpper(line[:idx])
|
||||||
|
line = line[idx+1:]
|
||||||
|
|
||||||
|
// Extract parameters.
|
||||||
|
for line != "" {
|
||||||
|
line = strings.TrimLeft(line, " ")
|
||||||
|
if line == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trailing parameter (everything after ':').
|
||||||
|
if line[0] == ':' {
|
||||||
|
msg.Params = append(msg.Params, line[1:])
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
idx = strings.IndexByte(line, ' ')
|
||||||
|
if idx < 0 {
|
||||||
|
msg.Params = append(msg.Params, line)
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.Params = append(msg.Params, line[:idx])
|
||||||
|
line = line[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatMessage formats an IRC message into wire protocol
|
||||||
|
// format (without the trailing CR-LF).
|
||||||
|
func FormatMessage(
|
||||||
|
prefix, command string,
|
||||||
|
params ...string,
|
||||||
|
) string {
|
||||||
|
var buf strings.Builder
|
||||||
|
|
||||||
|
if prefix != "" {
|
||||||
|
buf.WriteByte(':')
|
||||||
|
buf.WriteString(prefix)
|
||||||
|
buf.WriteByte(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.WriteString(command)
|
||||||
|
|
||||||
|
for i, param := range params {
|
||||||
|
buf.WriteByte(' ')
|
||||||
|
|
||||||
|
isLast := i == len(params)-1
|
||||||
|
needsColon := strings.Contains(param, " ") ||
|
||||||
|
param == "" || param[0] == ':'
|
||||||
|
|
||||||
|
if isLast && needsColon {
|
||||||
|
buf.WriteByte(':')
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.WriteString(param)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
328
internal/ircserver/parser_test.go
Normal file
328
internal/ircserver/parser_test.go
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
package ircserver_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/neoirc/internal/ircserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
//nolint:funlen // table-driven test
|
||||||
|
func TestParseMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want *ircserver.Message
|
||||||
|
wantNil bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
input: "",
|
||||||
|
want: nil,
|
||||||
|
wantNil: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple command",
|
||||||
|
input: "PING",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "PING",
|
||||||
|
Params: nil,
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "command with one param",
|
||||||
|
input: "NICK alice",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "NICK",
|
||||||
|
Params: []string{"alice"},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "command case insensitive",
|
||||||
|
input: "nick Alice",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "NICK",
|
||||||
|
Params: []string{"Alice"},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "privmsg with trailing",
|
||||||
|
input: "PRIVMSG #general :hello world",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "PRIVMSG",
|
||||||
|
Params: []string{"#general", "hello world"},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with prefix",
|
||||||
|
input: ":server.example.com 001 alice :Welcome to IRC",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "server.example.com",
|
||||||
|
Command: "001",
|
||||||
|
Params: []string{"alice", "Welcome to IRC"},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user command",
|
||||||
|
input: "USER alice 0 * :Alice Smith",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "USER",
|
||||||
|
Params: []string{
|
||||||
|
"alice", "0", "*", "Alice Smith",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "join channel",
|
||||||
|
input: "JOIN #general",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "JOIN",
|
||||||
|
Params: []string{"#general"},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quit with trailing",
|
||||||
|
input: "QUIT :leaving now",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "QUIT",
|
||||||
|
Params: []string{"leaving now"},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quit without reason",
|
||||||
|
input: "QUIT",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "QUIT",
|
||||||
|
Params: nil,
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mode query",
|
||||||
|
input: "MODE #general",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "MODE",
|
||||||
|
Params: []string{"#general"},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "kick with reason",
|
||||||
|
input: "KICK #general bob :misbehaving",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "KICK",
|
||||||
|
Params: []string{
|
||||||
|
"#general", "bob", "misbehaving",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty trailing",
|
||||||
|
input: "PRIVMSG #general :",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "PRIVMSG",
|
||||||
|
Params: []string{"#general", ""},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pass command",
|
||||||
|
input: "PASS mysecret",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "PASS",
|
||||||
|
Params: []string{"mysecret"},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ping with server",
|
||||||
|
input: "PING :irc.example.com",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "PING",
|
||||||
|
Params: []string{"irc.example.com"},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "topic with trailing spaces",
|
||||||
|
input: "TOPIC #general :Welcome to the channel!",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "TOPIC",
|
||||||
|
Params: []string{
|
||||||
|
"#general",
|
||||||
|
"Welcome to the channel!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range tests {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := ircserver.ParseMessage(testCase.input)
|
||||||
|
if testCase.wantNil {
|
||||||
|
if got != nil {
|
||||||
|
t.Fatalf("expected nil, got %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expected non-nil message")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Prefix != testCase.want.Prefix {
|
||||||
|
t.Errorf(
|
||||||
|
"prefix: got %q, want %q",
|
||||||
|
got.Prefix, testCase.want.Prefix,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Command != testCase.want.Command {
|
||||||
|
t.Errorf(
|
||||||
|
"command: got %q, want %q",
|
||||||
|
got.Command, testCase.want.Command,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Params) != len(testCase.want.Params) {
|
||||||
|
t.Fatalf(
|
||||||
|
"params length: got %d, want %d (%v vs %v)",
|
||||||
|
len(got.Params),
|
||||||
|
len(testCase.want.Params),
|
||||||
|
got.Params,
|
||||||
|
testCase.want.Params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, p := range got.Params {
|
||||||
|
if p != testCase.want.Params[i] {
|
||||||
|
t.Errorf(
|
||||||
|
"param[%d]: got %q, want %q",
|
||||||
|
i, p, testCase.want.Params[i],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prefix string
|
||||||
|
command string
|
||||||
|
params []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple command",
|
||||||
|
prefix: "",
|
||||||
|
command: "PING",
|
||||||
|
params: nil,
|
||||||
|
want: "PING",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with prefix",
|
||||||
|
prefix: "server",
|
||||||
|
command: "PONG",
|
||||||
|
params: []string{"server"},
|
||||||
|
want: ":server PONG server",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "privmsg with trailing",
|
||||||
|
prefix: "alice!alice@host",
|
||||||
|
command: "PRIVMSG",
|
||||||
|
params: []string{"#general", "hello world"},
|
||||||
|
want: ":alice!alice@host PRIVMSG #general :hello world",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "numeric reply",
|
||||||
|
prefix: "server",
|
||||||
|
command: "001",
|
||||||
|
params: []string{"alice", "Welcome to IRC"},
|
||||||
|
want: ":server 001 alice :Welcome to IRC",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty trailing",
|
||||||
|
prefix: "server",
|
||||||
|
command: "PRIVMSG",
|
||||||
|
params: []string{"#chan", ""},
|
||||||
|
want: ":server PRIVMSG #chan :",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range tests {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := ircserver.FormatMessage(
|
||||||
|
testCase.prefix, testCase.command, testCase.params...,
|
||||||
|
)
|
||||||
|
if got != testCase.want {
|
||||||
|
t.Errorf("got %q, want %q", got, testCase.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFormatRoundTrip(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Round-trip only works for lines where the last
|
||||||
|
// parameter either contains a space (gets ':' prefix
|
||||||
|
// on format) or is a non-trailing single token.
|
||||||
|
lines := []string{
|
||||||
|
"PING",
|
||||||
|
"NICK alice",
|
||||||
|
"PRIVMSG #general :hello world",
|
||||||
|
"JOIN #general",
|
||||||
|
"MODE #general",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
msg := ircserver.ParseMessage(line)
|
||||||
|
if msg == nil {
|
||||||
|
t.Fatalf("failed to parse: %q", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted := ircserver.FormatMessage(
|
||||||
|
msg.Prefix, msg.Command, msg.Params...,
|
||||||
|
)
|
||||||
|
if formatted != line {
|
||||||
|
t.Errorf(
|
||||||
|
"round-trip failed: input %q, got %q",
|
||||||
|
line, formatted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
333
internal/ircserver/relay.go
Normal file
333
internal/ircserver/relay.go
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
package ircserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// relayMessages polls the client output queue and delivers
|
||||||
|
// IRC-formatted messages to the TCP connection. It runs
|
||||||
|
// in a goroutine for the lifetime of the connection.
|
||||||
|
func (c *Conn) relayMessages(ctx context.Context) {
|
||||||
|
// Use a ticker as a fallback; primary wakeup is via
|
||||||
|
// broker notification.
|
||||||
|
ticker := time.NewTicker(pollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain any available messages.
|
||||||
|
delivered := c.drainQueue(ctx)
|
||||||
|
|
||||||
|
if delivered {
|
||||||
|
// Tight loop while there are messages.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for notification or timeout.
|
||||||
|
waitCh := c.brk.Wait(c.sessionID)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-waitCh:
|
||||||
|
// New message notification — loop back.
|
||||||
|
case <-ticker.C:
|
||||||
|
// Periodic check.
|
||||||
|
case <-ctx.Done():
|
||||||
|
c.brk.Remove(c.sessionID, waitCh)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const relayPollLimit = 100
|
||||||
|
|
||||||
|
// drainQueue polls the output queue and delivers all
|
||||||
|
// pending messages. Returns true if at least one message
|
||||||
|
// was delivered.
|
||||||
|
func (c *Conn) drainQueue(ctx context.Context) bool {
|
||||||
|
msgs, lastID, err := c.database.PollMessages(
|
||||||
|
ctx, c.clientID, c.lastQueueID, relayPollLimit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range msgs {
|
||||||
|
c.deliverIRCMessage(ctx, &msgs[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastID > c.lastQueueID {
|
||||||
|
c.lastQueueID = lastID
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverIRCMessage converts a db.IRCMessage to wire
|
||||||
|
// protocol and sends it.
|
||||||
|
//
|
||||||
|
//nolint:cyclop // dispatch table
|
||||||
|
func (c *Conn) deliverIRCMessage(
|
||||||
|
_ context.Context,
|
||||||
|
msg *db.IRCMessage,
|
||||||
|
) {
|
||||||
|
command := msg.Command
|
||||||
|
|
||||||
|
// Decode body as []string for the trailing text.
|
||||||
|
var bodyLines []string
|
||||||
|
|
||||||
|
if msg.Body != nil {
|
||||||
|
_ = json.Unmarshal(msg.Body, &bodyLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
text := ""
|
||||||
|
if len(bodyLines) > 0 {
|
||||||
|
text = bodyLines[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route by command type.
|
||||||
|
switch {
|
||||||
|
case isNumeric(command):
|
||||||
|
c.deliverNumeric(msg, text)
|
||||||
|
case command == irc.CmdPrivmsg || command == irc.CmdNotice:
|
||||||
|
c.deliverTextMessage(msg, command, text)
|
||||||
|
case command == irc.CmdJoin:
|
||||||
|
c.deliverJoin(msg)
|
||||||
|
case command == irc.CmdPart:
|
||||||
|
c.deliverPart(msg, text)
|
||||||
|
case command == irc.CmdNick:
|
||||||
|
c.deliverNickChange(msg, text)
|
||||||
|
case command == irc.CmdQuit:
|
||||||
|
c.deliverQuitMsg(msg, text)
|
||||||
|
case command == irc.CmdTopic:
|
||||||
|
c.deliverTopicChange(msg, text)
|
||||||
|
case command == irc.CmdKick:
|
||||||
|
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:
|
||||||
|
// Server-originated PING — reply with PONG.
|
||||||
|
c.sendFromServer("PING", c.serverSfx)
|
||||||
|
default:
|
||||||
|
// Unknown command — deliver as server notice.
|
||||||
|
if text != "" {
|
||||||
|
c.sendFromServer("NOTICE", c.nick, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNumeric returns true if the command is a 3-digit
|
||||||
|
// numeric code.
|
||||||
|
func isNumeric(cmd string) bool {
|
||||||
|
return len(cmd) == 3 &&
|
||||||
|
cmd[0] >= '0' && cmd[0] <= '9' &&
|
||||||
|
cmd[1] >= '0' && cmd[1] <= '9' &&
|
||||||
|
cmd[2] >= '0' && cmd[2] <= '9'
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverNumeric sends a numeric reply.
|
||||||
|
func (c *Conn) deliverNumeric(
|
||||||
|
msg *db.IRCMessage,
|
||||||
|
text string,
|
||||||
|
) {
|
||||||
|
from := msg.From
|
||||||
|
if from == "" {
|
||||||
|
from = c.serverSfx
|
||||||
|
}
|
||||||
|
|
||||||
|
var params []string
|
||||||
|
|
||||||
|
if msg.Params != nil {
|
||||||
|
_ = json.Unmarshal(msg.Params, ¶ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
allParams := make([]string, 0, 1+len(params)+1)
|
||||||
|
allParams = append(allParams, c.nick)
|
||||||
|
allParams = append(allParams, params...)
|
||||||
|
|
||||||
|
if text != "" {
|
||||||
|
allParams = append(allParams, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.send(FormatMessage(from, msg.Command, allParams...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverTextMessage sends PRIVMSG or NOTICE.
|
||||||
|
func (c *Conn) deliverTextMessage(
|
||||||
|
msg *db.IRCMessage,
|
||||||
|
command, text string,
|
||||||
|
) {
|
||||||
|
from := msg.From
|
||||||
|
target := msg.To
|
||||||
|
|
||||||
|
// Don't echo our own messages back.
|
||||||
|
if strings.EqualFold(from, c.nick) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := from
|
||||||
|
if !strings.Contains(prefix, "!") {
|
||||||
|
prefix = from + "!" + from + "@*"
|
||||||
|
}
|
||||||
|
|
||||||
|
c.send(FormatMessage(prefix, command, target, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverJoin sends a JOIN notification.
|
||||||
|
func (c *Conn) deliverJoin(msg *db.IRCMessage) {
|
||||||
|
// Don't echo our own JOINs (we already sent them
|
||||||
|
// during joinChannel).
|
||||||
|
if strings.EqualFold(msg.From, c.nick) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
|
channel := msg.To
|
||||||
|
|
||||||
|
c.send(FormatMessage(prefix, "JOIN", channel))
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverPart sends a PART notification.
|
||||||
|
func (c *Conn) deliverPart(msg *db.IRCMessage, text string) {
|
||||||
|
if strings.EqualFold(msg.From, c.nick) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
|
channel := msg.To
|
||||||
|
|
||||||
|
if text != "" {
|
||||||
|
c.send(FormatMessage(
|
||||||
|
prefix, "PART", channel, text,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
c.send(FormatMessage(prefix, "PART", channel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverNickChange sends a NICK change notification.
|
||||||
|
func (c *Conn) deliverNickChange(
|
||||||
|
msg *db.IRCMessage,
|
||||||
|
newNick string,
|
||||||
|
) {
|
||||||
|
if strings.EqualFold(msg.From, c.nick) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
|
|
||||||
|
c.send(FormatMessage(prefix, "NICK", newNick))
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverQuitMsg sends a QUIT notification.
|
||||||
|
func (c *Conn) deliverQuitMsg(
|
||||||
|
msg *db.IRCMessage,
|
||||||
|
text string,
|
||||||
|
) {
|
||||||
|
if strings.EqualFold(msg.From, c.nick) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
|
|
||||||
|
if text != "" {
|
||||||
|
c.send(FormatMessage(
|
||||||
|
prefix, "QUIT", "Quit: "+text,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
c.send(FormatMessage(prefix, "QUIT", "Quit"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverTopicChange sends a TOPIC change notification.
|
||||||
|
func (c *Conn) deliverTopicChange(
|
||||||
|
msg *db.IRCMessage,
|
||||||
|
text string,
|
||||||
|
) {
|
||||||
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
|
channel := msg.To
|
||||||
|
|
||||||
|
c.send(FormatMessage(prefix, "TOPIC", channel, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverKickMsg sends a KICK notification.
|
||||||
|
func (c *Conn) deliverKickMsg(
|
||||||
|
msg *db.IRCMessage,
|
||||||
|
text string,
|
||||||
|
) {
|
||||||
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
|
channel := msg.To
|
||||||
|
|
||||||
|
var params []string
|
||||||
|
|
||||||
|
if msg.Params != nil {
|
||||||
|
_ = json.Unmarshal(msg.Params, ¶ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
kickTarget := ""
|
||||||
|
if len(params) > 0 {
|
||||||
|
kickTarget = params[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if kickTarget != "" {
|
||||||
|
c.send(FormatMessage(
|
||||||
|
prefix, "KICK", channel, kickTarget, text,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
c.send(FormatMessage(
|
||||||
|
prefix, "KICK", channel, "?", text,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverInviteMsg sends an INVITE notification.
|
||||||
|
func (c *Conn) deliverInviteMsg(
|
||||||
|
_ *db.IRCMessage,
|
||||||
|
text string,
|
||||||
|
) {
|
||||||
|
c.sendFromServer("NOTICE", c.nick, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverWallops sends a WALLOPS notification.
|
||||||
|
func (c *Conn) deliverWallops(
|
||||||
|
msg *db.IRCMessage,
|
||||||
|
text string,
|
||||||
|
) {
|
||||||
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
|
|
||||||
|
c.send(FormatMessage(
|
||||||
|
prefix, irc.CmdWallops, text,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverMode sends a MODE change notification.
|
||||||
|
func (c *Conn) deliverMode(
|
||||||
|
msg *db.IRCMessage,
|
||||||
|
text string,
|
||||||
|
) {
|
||||||
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
|
target := msg.To
|
||||||
|
|
||||||
|
if text != "" {
|
||||||
|
c.send(FormatMessage(prefix, "MODE", target, text))
|
||||||
|
}
|
||||||
|
}
|
||||||
157
internal/ircserver/server.go
Normal file
157
internal/ircserver/server.go
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package ircserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"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/logger"
|
||||||
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Params defines the dependencies for creating an IRC
|
||||||
|
// Server.
|
||||||
|
type Params struct {
|
||||||
|
fx.In
|
||||||
|
|
||||||
|
Logger *logger.Logger
|
||||||
|
Config *config.Config
|
||||||
|
Database *db.Database
|
||||||
|
Broker *broker.Broker
|
||||||
|
Service *service.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server is the TCP IRC protocol server.
|
||||||
|
type Server struct {
|
||||||
|
log *slog.Logger
|
||||||
|
cfg *config.Config
|
||||||
|
database *db.Database
|
||||||
|
brk *broker.Broker
|
||||||
|
svc *service.Service
|
||||||
|
listener net.Listener
|
||||||
|
mu sync.Mutex
|
||||||
|
conns map[*Conn]struct{}
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new IRC Server and registers its lifecycle
|
||||||
|
// hooks. The listener is only started if IRC_LISTEN_ADDR
|
||||||
|
// is configured; otherwise the server is inert.
|
||||||
|
func New(
|
||||||
|
lifecycle fx.Lifecycle,
|
||||||
|
params Params,
|
||||||
|
) *Server {
|
||||||
|
srv := &Server{
|
||||||
|
log: params.Logger.Get(),
|
||||||
|
cfg: params.Config,
|
||||||
|
database: params.Database,
|
||||||
|
brk: params.Broker,
|
||||||
|
svc: params.Service,
|
||||||
|
conns: make(map[*Conn]struct{}),
|
||||||
|
listener: nil,
|
||||||
|
cancel: nil,
|
||||||
|
mu: sync.Mutex{},
|
||||||
|
}
|
||||||
|
|
||||||
|
listenAddr := params.Config.IRCListenAddr
|
||||||
|
if listenAddr == "" {
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle.Append(fx.Hook{
|
||||||
|
OnStart: func(ctx context.Context) error {
|
||||||
|
return srv.start(ctx, listenAddr)
|
||||||
|
},
|
||||||
|
OnStop: func(_ context.Context) error {
|
||||||
|
srv.stop()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("irc listen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.listener = ln
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
s.cancel = cancel
|
||||||
|
|
||||||
|
s.log.Info(
|
||||||
|
"irc server listening", "addr", addr,
|
||||||
|
)
|
||||||
|
|
||||||
|
go s.acceptLoop(ctx)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop shuts down the listener and all connections.
|
||||||
|
func (s *Server) stop() {
|
||||||
|
if s.cancel != nil {
|
||||||
|
s.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.listener != nil {
|
||||||
|
s.listener.Close() //nolint:errcheck,gosec
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
for c := range s.conns {
|
||||||
|
c.conn.Close() //nolint:errcheck,gosec
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// acceptLoop accepts new connections.
|
||||||
|
func (s *Server) acceptLoop(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
tcpConn, err := s.listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
s.log.Error(
|
||||||
|
"irc accept error", "error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := newConn(
|
||||||
|
ctx, tcpConn, s.log,
|
||||||
|
s.database, s.brk, s.cfg, s.svc,
|
||||||
|
)
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.conns[client] = struct{}{}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.conns, client)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
client.serve(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
771
internal/ircserver/server_test.go
Normal file
771
internal/ircserver/server_test.go
Normal file
@@ -0,0 +1,771 @@
|
|||||||
|
package ircserver_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/ircserver"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
db.SetBcryptCost(4)
|
||||||
|
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
|
||||||
|
// testEnv holds the shared test infrastructure.
|
||||||
|
type testEnv struct {
|
||||||
|
database *db.Database
|
||||||
|
brk *broker.Broker
|
||||||
|
cfg *config.Config
|
||||||
|
srv *ircserver.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
|
||||||
|
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: serverName,
|
||||||
|
MOTD: "Welcome to test IRC",
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := listener.Addr().String()
|
||||||
|
|
||||||
|
err = listener.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close listener: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log := slog.New(slog.NewTextHandler(
|
||||||
|
os.Stderr,
|
||||||
|
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
|
||||||
|
))
|
||||||
|
|
||||||
|
srv := ircserver.NewTestServer(log, cfg, database, brk)
|
||||||
|
|
||||||
|
err = srv.Start(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("start irc server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
srv.Stop()
|
||||||
|
|
||||||
|
err := conn.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("close db: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return &testEnv{
|
||||||
|
database: database,
|
||||||
|
brk: brk,
|
||||||
|
cfg: cfg,
|
||||||
|
srv: srv,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestEnvWithOper creates a test environment with oper
|
||||||
|
// credentials configured.
|
||||||
|
func newTestEnvWithOper(t *testing.T) *testEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dsn := fmt.Sprintf(
|
||||||
|
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
|
||||||
|
t.Name(),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn, err := sql.Open("sqlite", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
_, err = conn.ExecContext(
|
||||||
|
t.Context(), "PRAGMA foreign_keys = ON",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pragma: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
database := db.NewTestDatabaseFromConn(conn)
|
||||||
|
|
||||||
|
err = database.RunMigrations(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
brk := broker.New()
|
||||||
|
|
||||||
|
cfg := &config.Config{ //nolint:exhaustruct
|
||||||
|
ServerName: "test.irc",
|
||||||
|
MOTD: "Welcome to test IRC",
|
||||||
|
OperName: "testoper",
|
||||||
|
OperPassword: "testpass",
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := listener.Addr().String()
|
||||||
|
|
||||||
|
err = listener.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close listener: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log := slog.New(slog.NewTextHandler(
|
||||||
|
os.Stderr,
|
||||||
|
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
|
||||||
|
))
|
||||||
|
|
||||||
|
srv := ircserver.NewTestServer(log, cfg, database, brk)
|
||||||
|
|
||||||
|
err = srv.Start(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("start irc server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
srv.Stop()
|
||||||
|
|
||||||
|
err := conn.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("close db: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return &testEnv{
|
||||||
|
database: database,
|
||||||
|
brk: brk,
|
||||||
|
cfg: cfg,
|
||||||
|
srv: srv,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dial connects to the test server.
|
||||||
|
func (env *testEnv) dial(t *testing.T) *testClient {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
conn, err := net.DialTimeout(
|
||||||
|
"tcp",
|
||||||
|
env.srv.Listener().Addr().String(),
|
||||||
|
testTimeout,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err := conn.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("close conn: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return &testClient{
|
||||||
|
t: t,
|
||||||
|
conn: conn,
|
||||||
|
scanner: bufio.NewScanner(conn),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testClient wraps a raw TCP connection with helpers.
|
||||||
|
type testClient struct {
|
||||||
|
t *testing.T
|
||||||
|
conn net.Conn
|
||||||
|
scanner *bufio.Scanner
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *testClient) send(line string) {
|
||||||
|
tc.t.Helper()
|
||||||
|
|
||||||
|
_ = tc.conn.SetWriteDeadline(
|
||||||
|
time.Now().Add(testTimeout),
|
||||||
|
)
|
||||||
|
|
||||||
|
_, err := fmt.Fprintf(tc.conn, "%s\r\n", line)
|
||||||
|
if err != nil {
|
||||||
|
tc.t.Fatalf("send: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *testClient) readLine() string {
|
||||||
|
tc.t.Helper()
|
||||||
|
|
||||||
|
_ = tc.conn.SetReadDeadline(
|
||||||
|
time.Now().Add(testTimeout),
|
||||||
|
)
|
||||||
|
|
||||||
|
if !tc.scanner.Scan() {
|
||||||
|
err := tc.scanner.Err()
|
||||||
|
if err != nil {
|
||||||
|
tc.t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tc.t.Fatal("connection closed unexpectedly")
|
||||||
|
}
|
||||||
|
|
||||||
|
return tc.scanner.Text()
|
||||||
|
}
|
||||||
|
|
||||||
|
// readUntil reads lines until one matches the predicate.
|
||||||
|
func (tc *testClient) readUntil(
|
||||||
|
pred func(string) bool,
|
||||||
|
) []string {
|
||||||
|
tc.t.Helper()
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
|
||||||
|
for {
|
||||||
|
line := tc.readLine()
|
||||||
|
lines = append(lines, line)
|
||||||
|
|
||||||
|
if pred(line) {
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// register sends NICK + USER and reads through the welcome
|
||||||
|
// burst.
|
||||||
|
func (tc *testClient) register(nick string) []string {
|
||||||
|
tc.t.Helper()
|
||||||
|
|
||||||
|
tc.send("NICK " + nick)
|
||||||
|
tc.send("USER " + nick + " 0 * :Test User")
|
||||||
|
|
||||||
|
return tc.readUntil(func(line string) bool {
|
||||||
|
return strings.Contains(line, " 376 ") ||
|
||||||
|
strings.Contains(line, " 422 ")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
t *testing.T,
|
||||||
|
lines []string,
|
||||||
|
substr, description string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.Contains(line, substr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
tc.t.Helper()
|
||||||
|
|
||||||
|
tc.send("JOIN " + channel)
|
||||||
|
|
||||||
|
tc.readUntil(func(line string) bool {
|
||||||
|
return strings.Contains(line, " 366 ")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendAndExpect sends a command and reads until a line
|
||||||
|
// containing the expected substring is found.
|
||||||
|
func (tc *testClient) sendAndExpect(
|
||||||
|
cmd, expect string,
|
||||||
|
) []string {
|
||||||
|
tc.t.Helper()
|
||||||
|
|
||||||
|
tc.send(cmd)
|
||||||
|
|
||||||
|
return tc.readUntil(func(line string) bool {
|
||||||
|
return strings.Contains(line, expect)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistration(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
lines := client.register("alice")
|
||||||
|
assertContains(t, lines, " 001 ", "RPL_WELCOME")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWelcomeContainsNick(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
lines := client.register("bob")
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.Contains(line, " 001 ") &&
|
||||||
|
!strings.Contains(line, "bob") {
|
||||||
|
t.Errorf("001 should contain nick: %s", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPingPong(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("pingtest")
|
||||||
|
lines := client.sendAndExpect("PING :hello", "PONG")
|
||||||
|
assertContains(t, lines, "PONG", "PONG response")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJoinChannel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("joiner")
|
||||||
|
client.send("JOIN #test")
|
||||||
|
|
||||||
|
lines := client.readUntil(func(line string) bool {
|
||||||
|
return strings.Contains(line, " 366 ")
|
||||||
|
})
|
||||||
|
|
||||||
|
assertContains(t, lines, "JOIN", "JOIN echo")
|
||||||
|
assertContains(t, lines, " 366 ", "RPL_ENDOFNAMES")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrivmsgBetweenClients(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice_pm")
|
||||||
|
|
||||||
|
bob := env.dial(t)
|
||||||
|
bob.register("bob_pm")
|
||||||
|
|
||||||
|
alice.joinAndDrain("#chat")
|
||||||
|
bob.joinAndDrain("#chat")
|
||||||
|
|
||||||
|
alice.send("PRIVMSG #chat :hello bob!")
|
||||||
|
lines := bob.sendAndExpect("PING :sync", "hello bob!")
|
||||||
|
assertContains(t, lines, "hello bob!", "channel PRIVMSG")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNickChange(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("oldnick")
|
||||||
|
lines := client.sendAndExpect("NICK newnick", "newnick")
|
||||||
|
assertContains(t, lines, "NICK", "NICK change echo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDuplicateNick(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
first := env.dial(t)
|
||||||
|
first.register("taken")
|
||||||
|
|
||||||
|
second := env.dial(t)
|
||||||
|
second.send("NICK taken")
|
||||||
|
second.send("USER taken 0 * :Test")
|
||||||
|
|
||||||
|
lines := second.readUntil(func(line string) bool {
|
||||||
|
return strings.Contains(line, " 433 ")
|
||||||
|
})
|
||||||
|
|
||||||
|
assertContains(t, lines, " 433 ", "ERR_NICKNAMEINUSE")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListChannels(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("lister")
|
||||||
|
client.joinAndDrain("#listtest")
|
||||||
|
lines := client.sendAndExpect("LIST", " 323 ")
|
||||||
|
assertContains(t, lines, " 323 ", "RPL_LISTEND") //nolint:misspell // IRC term
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhois(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("whoistest")
|
||||||
|
lines := client.sendAndExpect(
|
||||||
|
"WHOIS whoistest", " 318 ",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertContains(t, lines, " 311 ", "RPL_WHOISUSER")
|
||||||
|
assertContains(t, lines, " 318 ", "RPL_ENDOFWHOIS")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("quitter")
|
||||||
|
lines := client.sendAndExpect(
|
||||||
|
"QUIT :goodbye", "ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertContains(t, lines, "goodbye", "QUIT reason")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTopicSetAndGet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("topicuser")
|
||||||
|
client.joinAndDrain("#topictest")
|
||||||
|
lines := client.sendAndExpect(
|
||||||
|
"TOPIC #topictest :New topic here",
|
||||||
|
"New topic here",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertContains(
|
||||||
|
t, lines, "New topic here", "TOPIC echo",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownCommand(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("unknowncmd")
|
||||||
|
lines := client.sendAndExpect("FOOBAR", " 421 ")
|
||||||
|
assertContains(t, lines, " 421 ", "ERR_UNKNOWNCOMMAND")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirectMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
sender := env.dial(t)
|
||||||
|
sender.register("dmsender")
|
||||||
|
|
||||||
|
receiver := env.dial(t)
|
||||||
|
receiver.register("dmreceiver")
|
||||||
|
|
||||||
|
// Give relay goroutines time to start.
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
sender.send("PRIVMSG dmreceiver :hello privately")
|
||||||
|
|
||||||
|
lines := receiver.readUntil(func(line string) bool {
|
||||||
|
return strings.Contains(line, "hello privately")
|
||||||
|
})
|
||||||
|
|
||||||
|
assertContains(
|
||||||
|
t, lines, "hello privately", "direct PRIVMSG",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCAPNegotiation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.send("CAP LS 302")
|
||||||
|
|
||||||
|
line := client.readLine()
|
||||||
|
if !strings.Contains(line, "CAP") {
|
||||||
|
t.Errorf("expected CAP response, got: %s", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
client.send("CAP END")
|
||||||
|
lines := client.register("capuser")
|
||||||
|
assertContains(t, lines, " 001 ", "registration after CAP")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartChannel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("parter")
|
||||||
|
client.joinAndDrain("#parttest")
|
||||||
|
lines := client.sendAndExpect(
|
||||||
|
"PART #parttest :leaving", "PART",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertContains(t, lines, "#parttest", "PART echo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModeQuery(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("modeuser")
|
||||||
|
client.joinAndDrain("#modetest")
|
||||||
|
lines := client.sendAndExpect(
|
||||||
|
"MODE #modetest", " 324 ",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertContains(
|
||||||
|
t, lines, " 324 ", "RPL_CHANNELMODEIS",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhoChannel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("whouser")
|
||||||
|
client.joinAndDrain("#whotest")
|
||||||
|
lines := client.sendAndExpect("WHO #whotest", " 315 ")
|
||||||
|
assertContains(t, lines, " 352 ", "RPL_WHOREPLY")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLusers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("luseruser")
|
||||||
|
lines := client.sendAndExpect("LUSERS", " 255 ")
|
||||||
|
assertContains(t, lines, " 251 ", "RPL_LUSERCLIENT")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMotd(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("motduser")
|
||||||
|
lines := client.sendAndExpect("MOTD", " 376 ")
|
||||||
|
assertContains(t, lines, " 376 ", "RPL_ENDOFMOTD")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAwaySetAndClear(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("awayuser")
|
||||||
|
|
||||||
|
setLines := client.sendAndExpect(
|
||||||
|
"AWAY :brb lunch", " 306 ",
|
||||||
|
)
|
||||||
|
assertContains(t, setLines, " 306 ", "RPL_NOWAWAY")
|
||||||
|
|
||||||
|
clearLines := client.sendAndExpect("AWAY", " 305 ")
|
||||||
|
assertContains(t, clearLines, " 305 ", "RPL_UNAWAY")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePassPostRegistration(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("passuser")
|
||||||
|
lines := client.sendAndExpect(
|
||||||
|
"PASS :mypassword123", "Password set",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertContains(
|
||||||
|
t, lines, "Password set", "password confirmation",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreRegistrationNotRegistered(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.send("PRIVMSG #test :hello")
|
||||||
|
|
||||||
|
line := client.readLine()
|
||||||
|
if !strings.Contains(line, " 451 ") {
|
||||||
|
t.Errorf(
|
||||||
|
"expected ERR_NOTREGISTERED (451), got: %s",
|
||||||
|
line,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamesNonExistentChannel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("namesuser")
|
||||||
|
lines := client.sendAndExpect(
|
||||||
|
"NAMES #doesnotexist", " 366 ",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertContains(
|
||||||
|
t, lines, " 366 ",
|
||||||
|
"RPL_ENDOFNAMES for non-existent channel",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkParseMessage(b *testing.B) {
|
||||||
|
line := ":nick!user@host PRIVMSG #channel :Hello, world!"
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
|
||||||
|
for range b.N {
|
||||||
|
_ = ircserver.ParseMessage(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkFormatMessage(b *testing.B) {
|
||||||
|
b.ResetTimer()
|
||||||
|
|
||||||
|
for range b.N {
|
||||||
|
_ = ircserver.FormatMessage(
|
||||||
|
"nick!user@host", "PRIVMSG",
|
||||||
|
"#channel", "Hello, world!",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/chat/internal/globals"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params defines the dependencies for creating a Logger.
|
// Params defines the dependencies for creating a Logger.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package middleware provides HTTP middleware for the chat server.
|
// Package middleware provides HTTP middleware for the neoirc server.
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,17 +7,17 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/chat/internal/config"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/logger"
|
|
||||||
basicauth "github.com/99designs/basicauth-go"
|
basicauth "github.com/99designs/basicauth-go"
|
||||||
chimw "github.com/go-chi/chi/middleware"
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||||
"github.com/slok/go-http-metrics/middleware/std"
|
"github.com/slok/go-http-metrics/middleware/std"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
const corsMaxAge = 300
|
const corsMaxAge = 300
|
||||||
@@ -126,36 +126,27 @@ func (mware *Middleware) Logging() func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CORS returns middleware that handles Cross-Origin Resource Sharing.
|
// CORS returns middleware that handles Cross-Origin Resource Sharing.
|
||||||
|
// AllowCredentials is true so browsers include cookies in
|
||||||
|
// cross-origin API requests.
|
||||||
func (mware *Middleware) CORS() func(http.Handler) http.Handler {
|
func (mware *Middleware) CORS() func(http.Handler) http.Handler {
|
||||||
return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields
|
return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields
|
||||||
AllowedOrigins: []string{"*"},
|
AllowOriginFunc: func(
|
||||||
|
_ *http.Request, _ string,
|
||||||
|
) bool {
|
||||||
|
return true
|
||||||
|
},
|
||||||
AllowedMethods: []string{
|
AllowedMethods: []string{
|
||||||
"GET", "POST", "PUT", "DELETE", "OPTIONS",
|
"GET", "POST", "PUT", "DELETE", "OPTIONS",
|
||||||
},
|
},
|
||||||
AllowedHeaders: []string{
|
AllowedHeaders: []string{
|
||||||
"Accept", "Authorization",
|
"Accept", "Content-Type", "X-CSRF-Token",
|
||||||
"Content-Type", "X-CSRF-Token",
|
|
||||||
},
|
},
|
||||||
ExposedHeaders: []string{"Link"},
|
ExposedHeaders: []string{"Link"},
|
||||||
AllowCredentials: false,
|
AllowCredentials: true,
|
||||||
MaxAge: corsMaxAge,
|
MaxAge: corsMaxAge,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth returns middleware that performs authentication.
|
|
||||||
func (mware *Middleware) Auth() func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(
|
|
||||||
func(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
) {
|
|
||||||
mware.log.Info("AUTH: before request")
|
|
||||||
next.ServeHTTP(writer, request)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Metrics returns middleware that records HTTP metrics.
|
// Metrics returns middleware that records HTTP metrics.
|
||||||
func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
|
func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||||
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields
|
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields
|
||||||
@@ -180,3 +171,36 @@ func (mware *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cspPolicy is the Content-Security-Policy header value applied to all
|
||||||
|
// responses. The embedded SPA loads scripts and styles from same-origin
|
||||||
|
// files only (no inline scripts or inline style attributes), so a strict
|
||||||
|
// policy works without 'unsafe-inline'.
|
||||||
|
const cspPolicy = "default-src 'self'; " +
|
||||||
|
"script-src 'self'; " +
|
||||||
|
"style-src 'self'; " +
|
||||||
|
"connect-src 'self'; " +
|
||||||
|
"img-src 'self'; " +
|
||||||
|
"font-src 'self'; " +
|
||||||
|
"object-src 'none'; " +
|
||||||
|
"frame-ancestors 'none'; " +
|
||||||
|
"base-uri 'self'; " +
|
||||||
|
"form-action 'self'"
|
||||||
|
|
||||||
|
// CSP returns middleware that sets the Content-Security-Policy header on
|
||||||
|
// every response for defense-in-depth against XSS.
|
||||||
|
func (mware *Middleware) CSP() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(
|
||||||
|
func(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
) {
|
||||||
|
writer.Header().Set(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
cspPolicy,
|
||||||
|
)
|
||||||
|
next.ServeHTTP(writer, request)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
122
internal/ratelimit/ratelimit.go
Normal file
122
internal/ratelimit/ratelimit.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
// Package ratelimit provides per-IP rate limiting for HTTP endpoints.
|
||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultRate is the default number of allowed requests per second.
|
||||||
|
DefaultRate = 1.0
|
||||||
|
|
||||||
|
// DefaultBurst is the default maximum burst size.
|
||||||
|
DefaultBurst = 5
|
||||||
|
|
||||||
|
// DefaultSweepInterval controls how often stale entries are pruned.
|
||||||
|
DefaultSweepInterval = 10 * time.Minute
|
||||||
|
|
||||||
|
// DefaultEntryTTL is how long an unused entry lives before eviction.
|
||||||
|
DefaultEntryTTL = 15 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// entry tracks a per-IP rate limiter and when it was last used.
|
||||||
|
type entry struct {
|
||||||
|
limiter *rate.Limiter
|
||||||
|
lastSeen time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limiter manages per-key rate limiters with automatic cleanup
|
||||||
|
// of stale entries.
|
||||||
|
type Limiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[string]*entry
|
||||||
|
rate rate.Limit
|
||||||
|
burst int
|
||||||
|
entryTTL time.Duration
|
||||||
|
stopCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new per-key rate Limiter.
|
||||||
|
// The ratePerSec parameter sets how many requests per second are
|
||||||
|
// allowed per key. The burst parameter sets the maximum number of
|
||||||
|
// requests that can be made in a single burst.
|
||||||
|
func New(ratePerSec float64, burst int) *Limiter {
|
||||||
|
limiter := &Limiter{
|
||||||
|
mu: sync.Mutex{},
|
||||||
|
entries: make(map[string]*entry),
|
||||||
|
rate: rate.Limit(ratePerSec),
|
||||||
|
burst: burst,
|
||||||
|
entryTTL: DefaultEntryTTL,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
go limiter.sweepLoop()
|
||||||
|
|
||||||
|
return limiter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow reports whether a request from the given key should be
|
||||||
|
// allowed. It consumes one token from the key's rate limiter.
|
||||||
|
func (l *Limiter) Allow(key string) bool {
|
||||||
|
l.mu.Lock()
|
||||||
|
ent, exists := l.entries[key]
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
ent = &entry{
|
||||||
|
limiter: rate.NewLimiter(l.rate, l.burst),
|
||||||
|
lastSeen: time.Now(),
|
||||||
|
}
|
||||||
|
l.entries[key] = ent
|
||||||
|
} else {
|
||||||
|
ent.lastSeen = time.Now()
|
||||||
|
}
|
||||||
|
l.mu.Unlock()
|
||||||
|
|
||||||
|
return ent.limiter.Allow()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop terminates the background sweep goroutine.
|
||||||
|
func (l *Limiter) Stop() {
|
||||||
|
close(l.stopCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns the number of tracked keys (for testing).
|
||||||
|
func (l *Limiter) Len() int {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
return len(l.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepLoop periodically removes entries that haven't been seen
|
||||||
|
// within the TTL.
|
||||||
|
func (l *Limiter) sweepLoop() {
|
||||||
|
ticker := time.NewTicker(DefaultSweepInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
l.sweep()
|
||||||
|
case <-l.stopCh:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweep removes stale entries.
|
||||||
|
func (l *Limiter) sweep() {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
cutoff := time.Now().Add(-l.entryTTL)
|
||||||
|
|
||||||
|
for key, ent := range l.entries {
|
||||||
|
if ent.lastSeen.Before(cutoff) {
|
||||||
|
delete(l.entries, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
internal/ratelimit/ratelimit_test.go
Normal file
106
internal/ratelimit/ratelimit_test.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package ratelimit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/neoirc/internal/ratelimit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewCreatesLimiter(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(1.0, 5)
|
||||||
|
defer limiter.Stop()
|
||||||
|
|
||||||
|
if limiter == nil {
|
||||||
|
t.Fatal("expected non-nil limiter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowWithinBurst(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(1.0, 3)
|
||||||
|
defer limiter.Stop()
|
||||||
|
|
||||||
|
for i := range 3 {
|
||||||
|
if !limiter.Allow("192.168.1.1") {
|
||||||
|
t.Fatalf(
|
||||||
|
"request %d should be allowed within burst",
|
||||||
|
i+1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowExceedsBurst(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Rate of 0 means no token replenishment, only burst.
|
||||||
|
limiter := ratelimit.New(0, 3)
|
||||||
|
defer limiter.Stop()
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
limiter.Allow("10.0.0.1")
|
||||||
|
}
|
||||||
|
|
||||||
|
if limiter.Allow("10.0.0.1") {
|
||||||
|
t.Fatal("fourth request should be denied after burst exhausted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowSeparateKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Rate of 0, burst of 1 — only one request per key.
|
||||||
|
limiter := ratelimit.New(0, 1)
|
||||||
|
defer limiter.Stop()
|
||||||
|
|
||||||
|
if !limiter.Allow("10.0.0.1") {
|
||||||
|
t.Fatal("first request for key A should be allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !limiter.Allow("10.0.0.2") {
|
||||||
|
t.Fatal("first request for key B should be allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if limiter.Allow("10.0.0.1") {
|
||||||
|
t.Fatal("second request for key A should be denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
if limiter.Allow("10.0.0.2") {
|
||||||
|
t.Fatal("second request for key B should be denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLenTracksKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(1.0, 5)
|
||||||
|
defer limiter.Stop()
|
||||||
|
|
||||||
|
if limiter.Len() != 0 {
|
||||||
|
t.Fatalf("expected 0 entries, got %d", limiter.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
limiter.Allow("10.0.0.1")
|
||||||
|
limiter.Allow("10.0.0.2")
|
||||||
|
|
||||||
|
if limiter.Len() != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", limiter.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same key again should not increase count.
|
||||||
|
limiter.Allow("10.0.0.1")
|
||||||
|
|
||||||
|
if limiter.Len() != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", limiter.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopDoesNotPanic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(1.0, 5)
|
||||||
|
limiter.Stop()
|
||||||
|
}
|
||||||
@@ -5,11 +5,11 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/chat/web"
|
"sneak.berlin/go/neoirc/web"
|
||||||
|
|
||||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
@@ -29,6 +29,7 @@ func (srv *Server) SetupRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
srv.router.Use(srv.mw.CORS())
|
srv.router.Use(srv.mw.CORS())
|
||||||
|
srv.router.Use(srv.mw.CSP())
|
||||||
srv.router.Use(middleware.Timeout(routeTimeout))
|
srv.router.Use(middleware.Timeout(routeTimeout))
|
||||||
|
|
||||||
if srv.sentryEnabled {
|
if srv.sentryEnabled {
|
||||||
@@ -74,10 +75,6 @@ func (srv *Server) setupAPIv1(router chi.Router) {
|
|||||||
"/session",
|
"/session",
|
||||||
srv.handlers.HandleCreateSession(),
|
srv.handlers.HandleCreateSession(),
|
||||||
)
|
)
|
||||||
router.Post(
|
|
||||||
"/register",
|
|
||||||
srv.handlers.HandleRegister(),
|
|
||||||
)
|
|
||||||
router.Post(
|
router.Post(
|
||||||
"/login",
|
"/login",
|
||||||
srv.handlers.HandleLogin(),
|
srv.handlers.HandleLogin(),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package server implements the main HTTP server for the chat application.
|
// Package server implements the main HTTP server for the neoirc application.
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,15 +12,15 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/chat/internal/config"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/handlers"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/logger"
|
|
||||||
"git.eeqj.de/sneak/chat/internal/middleware"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
|
"sneak.berlin/go/neoirc/internal/handlers"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
"sneak.berlin/go/neoirc/internal/middleware"
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||||
)
|
)
|
||||||
@@ -45,7 +45,6 @@ type Params struct {
|
|||||||
// It manages routing, middleware, and lifecycle.
|
// It manages routing, middleware, and lifecycle.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
startupTime time.Time
|
startupTime time.Time
|
||||||
exitCode int
|
|
||||||
sentryEnabled bool
|
sentryEnabled bool
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
ctx context.Context //nolint:containedctx // signal handling pattern
|
ctx context.Context //nolint:containedctx // signal handling pattern
|
||||||
@@ -71,7 +70,17 @@ func New(
|
|||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(_ context.Context) error {
|
||||||
srv.startupTime = time.Now()
|
srv.startupTime = time.Now()
|
||||||
go srv.Run() //nolint:contextcheck
|
// 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.
|
||||||
|
srv.configure()
|
||||||
|
srv.enableSentry()
|
||||||
|
srv.SetupRoutes()
|
||||||
|
go srv.serve() //nolint:contextcheck
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -83,13 +92,6 @@ func New(
|
|||||||
return srv, nil
|
return srv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the server configuration, Sentry, and begins serving.
|
|
||||||
func (srv *Server) Run() {
|
|
||||||
srv.configure()
|
|
||||||
srv.enableSentry()
|
|
||||||
srv.serve()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeHTTP delegates to the chi router.
|
// ServeHTTP delegates to the chi router.
|
||||||
func (srv *Server) ServeHTTP(
|
func (srv *Server) ServeHTTP(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
@@ -127,7 +129,7 @@ func (srv *Server) enableSentry() {
|
|||||||
srv.sentryEnabled = true
|
srv.sentryEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Server) serve() int {
|
func (srv *Server) serve() {
|
||||||
srv.ctx, srv.cancelFunc = context.WithCancel(
|
srv.ctx, srv.cancelFunc = context.WithCancel(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
)
|
)
|
||||||
@@ -152,8 +154,6 @@ func (srv *Server) serve() int {
|
|||||||
<-srv.ctx.Done()
|
<-srv.ctx.Done()
|
||||||
|
|
||||||
srv.cleanShutdown()
|
srv.cleanShutdown()
|
||||||
|
|
||||||
return srv.exitCode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Server) cleanupForExit() {
|
func (srv *Server) cleanupForExit() {
|
||||||
@@ -161,8 +161,6 @@ func (srv *Server) cleanupForExit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Server) cleanShutdown() {
|
func (srv *Server) cleanShutdown() {
|
||||||
srv.exitCode = 0
|
|
||||||
|
|
||||||
ctxShutdown, shutdownCancel := context.WithTimeout(
|
ctxShutdown, shutdownCancel := context.WithTimeout(
|
||||||
context.Background(), shutdownTimeout,
|
context.Background(), shutdownTimeout,
|
||||||
)
|
)
|
||||||
@@ -202,8 +200,6 @@ func (srv *Server) serveUntilShutdown() {
|
|||||||
Handler: srv,
|
Handler: srv,
|
||||||
}
|
}
|
||||||
|
|
||||||
srv.SetupRoutes()
|
|
||||||
|
|
||||||
srv.log.Info(
|
srv.log.Info(
|
||||||
"http begin listen", "listenaddr", listenAddr,
|
"http begin listen", "listenaddr", listenAddr,
|
||||||
)
|
)
|
||||||
|
|||||||
1209
internal/service/service.go
Normal file
1209
internal/service/service.go
Normal file
File diff suppressed because it is too large
Load Diff
814
internal/service/service_test.go
Normal file
814
internal/service/service_test.go
Normal file
@@ -0,0 +1,814 @@
|
|||||||
|
// Tests use a global viper instance for configuration,
|
||||||
|
// making parallel execution unsafe.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest
|
||||||
|
package service_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"go.uber.org/fx/fxtest"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"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/internal/service"
|
||||||
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
db.SetBcryptCost(bcrypt.MinCost)
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
|
||||||
|
// testEnv holds all dependencies for a service test.
|
||||||
|
type testEnv struct {
|
||||||
|
svc *service.Service
|
||||||
|
db *db.Database
|
||||||
|
broker *broker.Broker
|
||||||
|
app *fxtest.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestEnv(t *testing.T) *testEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dbURL := fmt.Sprintf(
|
||||||
|
"file:svc_test_%p?mode=memory&cache=shared",
|
||||||
|
t,
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
database *db.Database
|
||||||
|
svc *service.Service
|
||||||
|
)
|
||||||
|
|
||||||
|
brk := broker.New()
|
||||||
|
|
||||||
|
app := fxtest.New(t,
|
||||||
|
fx.Provide(
|
||||||
|
func() *globals.Globals {
|
||||||
|
return &globals.Globals{
|
||||||
|
Appname: "neoirc-test",
|
||||||
|
Version: "test",
|
||||||
|
StartTime: time.Now(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
logger.New,
|
||||||
|
func(
|
||||||
|
lifecycle fx.Lifecycle,
|
||||||
|
globs *globals.Globals,
|
||||||
|
log *logger.Logger,
|
||||||
|
) (*config.Config, error) {
|
||||||
|
cfg, err := config.New(
|
||||||
|
lifecycle, config.Params{ //nolint:exhaustruct
|
||||||
|
Globals: globs, Logger: log,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"test config: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.DBURL = dbURL
|
||||||
|
cfg.Port = 0
|
||||||
|
cfg.OperName = "admin"
|
||||||
|
cfg.OperPassword = "secret"
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
},
|
||||||
|
func(
|
||||||
|
lifecycle fx.Lifecycle,
|
||||||
|
log *logger.Logger,
|
||||||
|
cfg *config.Config,
|
||||||
|
) (*db.Database, error) {
|
||||||
|
return db.New(lifecycle, db.Params{ //nolint:exhaustruct
|
||||||
|
Logger: log, Config: cfg,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
func() *broker.Broker { return brk },
|
||||||
|
service.New,
|
||||||
|
),
|
||||||
|
fx.Populate(&database, &svc),
|
||||||
|
)
|
||||||
|
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
app.RequireStop()
|
||||||
|
})
|
||||||
|
|
||||||
|
return &testEnv{
|
||||||
|
svc: svc,
|
||||||
|
db: database,
|
||||||
|
broker: brk,
|
||||||
|
app: app,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSession is a test helper that creates a session
|
||||||
|
// and returns the session ID.
|
||||||
|
func createSession(
|
||||||
|
ctx context.Context,
|
||||||
|
t *testing.T,
|
||||||
|
database *db.Database,
|
||||||
|
nick string,
|
||||||
|
) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sessionID, _, _, err := database.CreateSession(
|
||||||
|
ctx, nick, nick, "localhost", "127.0.0.1",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create session %s: %v", nick, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFanOut(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sid1 := createSession(ctx, t, env.db, "alice")
|
||||||
|
sid2 := createSession(ctx, t, env.db, "bob")
|
||||||
|
|
||||||
|
body, _ := json.Marshal([]string{"hello"}) //nolint:errchkjson
|
||||||
|
|
||||||
|
dbID, uuid, err := env.svc.FanOut(
|
||||||
|
ctx, irc.CmdPrivmsg, "alice", "#test",
|
||||||
|
nil, body, nil,
|
||||||
|
[]int64{sid1, sid2},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FanOut: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbID == 0 {
|
||||||
|
t.Error("expected non-zero dbID")
|
||||||
|
}
|
||||||
|
|
||||||
|
if uuid == "" {
|
||||||
|
t.Error("expected non-empty UUID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJoinChannel(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sid := createSession(ctx, t, env.db, "alice")
|
||||||
|
|
||||||
|
result, err := env.svc.JoinChannel(
|
||||||
|
ctx, sid, "alice", "#general", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("JoinChannel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ChannelID == 0 {
|
||||||
|
t.Error("expected non-zero channel ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.IsCreator {
|
||||||
|
t.Error("first joiner should be creator")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second user joins — not creator.
|
||||||
|
sid2 := createSession(ctx, t, env.db, "bob")
|
||||||
|
|
||||||
|
result2, err := env.svc.JoinChannel(
|
||||||
|
ctx, sid2, "bob", "#general", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("JoinChannel bob: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result2.IsCreator {
|
||||||
|
t.Error("second joiner should not be creator")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result2.ChannelID != result.ChannelID {
|
||||||
|
t.Error("both should join the same channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartChannel(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sid := createSession(ctx, t, env.db, "alice")
|
||||||
|
|
||||||
|
_, err := env.svc.JoinChannel(
|
||||||
|
ctx, sid, "alice", "#general", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("JoinChannel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = env.svc.PartChannel(
|
||||||
|
ctx, sid, "alice", "#general", "bye",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PartChannel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parting a non-existent channel returns error.
|
||||||
|
err = env.svc.PartChannel(
|
||||||
|
ctx, sid, "alice", "#nonexistent", "",
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for non-existent channel")
|
||||||
|
}
|
||||||
|
|
||||||
|
var ircErr *service.IRCError
|
||||||
|
if !errors.As(err, &ircErr) {
|
||||||
|
t.Errorf("expected IRCError, got %T", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendChannelMessage(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sid1 := createSession(ctx, t, env.db, "alice")
|
||||||
|
sid2 := createSession(ctx, t, env.db, "bob")
|
||||||
|
|
||||||
|
_, err := env.svc.JoinChannel(
|
||||||
|
ctx, sid1, "alice", "#chat", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("join alice: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = env.svc.JoinChannel(
|
||||||
|
ctx, sid2, "bob", "#chat", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("join bob: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal([]string{"hello world"}) //nolint:errchkjson
|
||||||
|
|
||||||
|
dbID, uuid, err := env.svc.SendChannelMessage(
|
||||||
|
ctx, sid1, "alice",
|
||||||
|
irc.CmdPrivmsg, "#chat", body, nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendChannelMessage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbID == 0 {
|
||||||
|
t.Error("expected non-zero dbID")
|
||||||
|
}
|
||||||
|
|
||||||
|
if uuid == "" {
|
||||||
|
t.Error("expected non-empty UUID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-member cannot send.
|
||||||
|
sid3 := createSession(ctx, t, env.db, "charlie")
|
||||||
|
|
||||||
|
_, _, err = env.svc.SendChannelMessage(
|
||||||
|
ctx, sid3, "charlie",
|
||||||
|
irc.CmdPrivmsg, "#chat", body, nil,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for non-member send")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBroadcastQuit(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sid1 := createSession(ctx, t, env.db, "alice")
|
||||||
|
sid2 := createSession(ctx, t, env.db, "bob")
|
||||||
|
|
||||||
|
_, err := env.svc.JoinChannel(
|
||||||
|
ctx, sid1, "alice", "#room", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("join alice: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = env.svc.JoinChannel(
|
||||||
|
ctx, sid2, "bob", "#room", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("join bob: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastQuit should not panic and should clean up.
|
||||||
|
env.svc.BroadcastQuit(
|
||||||
|
ctx, sid1, "alice", "Goodbye",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session should be deleted.
|
||||||
|
_, lookupErr := env.db.GetSessionByNick(ctx, "alice")
|
||||||
|
if lookupErr == nil {
|
||||||
|
t.Error("expected session to be deleted after quit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendChannelMessage_Moderated(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sid1 := createSession(ctx, t, env.db, "alice")
|
||||||
|
sid2 := createSession(ctx, t, env.db, "bob")
|
||||||
|
|
||||||
|
result, err := env.svc.JoinChannel(
|
||||||
|
ctx, sid1, "alice", "#modchat", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("join alice: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = env.svc.JoinChannel(
|
||||||
|
ctx, sid2, "bob", "#modchat", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("join bob: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set channel to moderated.
|
||||||
|
chID := result.ChannelID
|
||||||
|
_ = env.svc.SetChannelFlag(ctx, chID, 'm', true)
|
||||||
|
|
||||||
|
body, _ := json.Marshal([]string{"test"}) //nolint:errchkjson
|
||||||
|
|
||||||
|
// Bob (non-op, non-voiced) should fail to send.
|
||||||
|
_, _, err = env.svc.SendChannelMessage(
|
||||||
|
ctx, sid2, "bob",
|
||||||
|
irc.CmdPrivmsg, "#modchat", body, nil,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for non-voiced user in moderated channel")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alice (operator) should succeed.
|
||||||
|
_, _, err = env.svc.SendChannelMessage(
|
||||||
|
ctx, sid1, "alice",
|
||||||
|
irc.CmdPrivmsg, "#modchat", body, nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
52
internal/stats/stats.go
Normal file
52
internal/stats/stats.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// Package stats tracks runtime statistics since server boot.
|
||||||
|
package stats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tracker holds atomic counters for runtime statistics
|
||||||
|
// that accumulate since the server started.
|
||||||
|
type Tracker struct {
|
||||||
|
connectionsSinceBoot atomic.Int64
|
||||||
|
sessionsSinceBoot atomic.Int64
|
||||||
|
messagesSinceBoot atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Tracker with all counters at zero.
|
||||||
|
func New() *Tracker {
|
||||||
|
return &Tracker{} //nolint:exhaustruct // atomic fields have zero-value defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrConnections increments the total connection count.
|
||||||
|
func (t *Tracker) IncrConnections() {
|
||||||
|
t.connectionsSinceBoot.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrSessions increments the total session count.
|
||||||
|
func (t *Tracker) IncrSessions() {
|
||||||
|
t.sessionsSinceBoot.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrMessages increments the total PRIVMSG/NOTICE count.
|
||||||
|
func (t *Tracker) IncrMessages() {
|
||||||
|
t.messagesSinceBoot.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectionsSinceBoot returns the total number of
|
||||||
|
// client connections since boot.
|
||||||
|
func (t *Tracker) ConnectionsSinceBoot() int64 {
|
||||||
|
return t.connectionsSinceBoot.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionsSinceBoot returns the total number of sessions
|
||||||
|
// created since boot.
|
||||||
|
func (t *Tracker) SessionsSinceBoot() int64 {
|
||||||
|
return t.sessionsSinceBoot.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessagesSinceBoot returns the total number of
|
||||||
|
// PRIVMSG/NOTICE messages sent since boot.
|
||||||
|
func (t *Tracker) MessagesSinceBoot() int64 {
|
||||||
|
return t.messagesSinceBoot.Load()
|
||||||
|
}
|
||||||
117
internal/stats/stats_test.go
Normal file
117
internal/stats/stats_test.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package stats_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/neoirc/internal/stats"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNew(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tracker := stats.New()
|
||||||
|
if tracker == nil {
|
||||||
|
t.Fatal("expected non-nil tracker")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tracker.ConnectionsSinceBoot() != 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected 0 connections, got %d",
|
||||||
|
tracker.ConnectionsSinceBoot(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tracker.SessionsSinceBoot() != 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected 0 sessions, got %d",
|
||||||
|
tracker.SessionsSinceBoot(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tracker.MessagesSinceBoot() != 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected 0 messages, got %d",
|
||||||
|
tracker.MessagesSinceBoot(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncrConnections(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tracker := stats.New()
|
||||||
|
|
||||||
|
tracker.IncrConnections()
|
||||||
|
tracker.IncrConnections()
|
||||||
|
tracker.IncrConnections()
|
||||||
|
|
||||||
|
got := tracker.ConnectionsSinceBoot()
|
||||||
|
if got != 3 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected 3 connections, got %d", got,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncrSessions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tracker := stats.New()
|
||||||
|
|
||||||
|
tracker.IncrSessions()
|
||||||
|
tracker.IncrSessions()
|
||||||
|
|
||||||
|
got := tracker.SessionsSinceBoot()
|
||||||
|
if got != 2 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected 2 sessions, got %d", got,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncrMessages(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tracker := stats.New()
|
||||||
|
|
||||||
|
tracker.IncrMessages()
|
||||||
|
|
||||||
|
got := tracker.MessagesSinceBoot()
|
||||||
|
if got != 1 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected 1 message, got %d", got,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCountersAreIndependent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tracker := stats.New()
|
||||||
|
|
||||||
|
tracker.IncrConnections()
|
||||||
|
tracker.IncrSessions()
|
||||||
|
tracker.IncrMessages()
|
||||||
|
tracker.IncrMessages()
|
||||||
|
|
||||||
|
if tracker.ConnectionsSinceBoot() != 1 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected 1 connection, got %d",
|
||||||
|
tracker.ConnectionsSinceBoot(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tracker.SessionsSinceBoot() != 1 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected 1 session, got %d",
|
||||||
|
tracker.SessionsSinceBoot(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tracker.MessagesSinceBoot() != 2 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected 2 messages, got %d",
|
||||||
|
tracker.MessagesSinceBoot(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
34
pkg/irc/commands.go
Normal file
34
pkg/irc/commands.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
393
pkg/irc/numerics.go
Normal file
393
pkg/irc/numerics.go
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
// Package irc provides constants and utilities for the
|
||||||
|
// IRC protocol, including numeric reply codes from
|
||||||
|
// RFC 1459 and RFC 2812, and standard command names.
|
||||||
|
package irc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IRCMessageType represents an IRC numeric reply or error code.
|
||||||
|
type IRCMessageType int //nolint:revive // Name requested by project owner.
|
||||||
|
|
||||||
|
// Name returns the standard IRC name for this numeric code
|
||||||
|
// (e.g., IRCMessageType(252).Name() returns "RPL_LUSEROP").
|
||||||
|
// Returns an empty string if the code is unknown.
|
||||||
|
func (t IRCMessageType) Name() string {
|
||||||
|
return names[t]
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the name and numeric code in angle brackets
|
||||||
|
// (e.g., IRCMessageType(252).String() returns "RPL_LUSEROP <252>").
|
||||||
|
// If the code is unknown, returns "UNKNOWN <NNN>".
|
||||||
|
func (t IRCMessageType) String() string {
|
||||||
|
n := names[t]
|
||||||
|
if n == "" {
|
||||||
|
n = "UNKNOWN"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s <%03d>", n, int(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code returns the three-digit zero-padded string representation
|
||||||
|
// of the numeric code (e.g., IRCMessageType(252).Code() returns "252").
|
||||||
|
func (t IRCMessageType) Code() string {
|
||||||
|
return fmt.Sprintf("%03d", int(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Int returns the bare integer value of the numeric code.
|
||||||
|
func (t IRCMessageType) Int() int {
|
||||||
|
return int(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrUnknownNumeric is returned by FromInt when the numeric code is not recognized.
|
||||||
|
var ErrUnknownNumeric = errors.New("unknown IRC numeric code")
|
||||||
|
|
||||||
|
// FromInt converts an integer to an IRCMessageType, returning an error
|
||||||
|
// if the numeric code is not a known IRC reply or error code.
|
||||||
|
func FromInt(n int) (IRCMessageType, error) {
|
||||||
|
t := IRCMessageType(n)
|
||||||
|
if _, ok := names[t]; !ok {
|
||||||
|
return 0, fmt.Errorf("%w: %d", ErrUnknownNumeric, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection registration replies (001-005).
|
||||||
|
const (
|
||||||
|
RplWelcome IRCMessageType = 1
|
||||||
|
RplYourHost IRCMessageType = 2
|
||||||
|
RplCreated IRCMessageType = 3
|
||||||
|
RplMyInfo IRCMessageType = 4
|
||||||
|
RplBounce IRCMessageType = 5 // RFC 2812; also known as RPL_ISUPPORT in practice
|
||||||
|
RplIsupport IRCMessageType = 5 // De-facto standard (same numeric as RplBounce)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Command responses (200-399).
|
||||||
|
const (
|
||||||
|
// RFC 2812 trace/stats/links replies (200-219).
|
||||||
|
RplTraceLink IRCMessageType = 200
|
||||||
|
RplTraceConnecting IRCMessageType = 201
|
||||||
|
RplTraceHandshake IRCMessageType = 202
|
||||||
|
RplTraceUnknown IRCMessageType = 203
|
||||||
|
RplTraceOperator IRCMessageType = 204
|
||||||
|
RplTraceUser IRCMessageType = 205
|
||||||
|
RplTraceServer IRCMessageType = 206
|
||||||
|
RplTraceService IRCMessageType = 207
|
||||||
|
RplTraceNewType IRCMessageType = 208
|
||||||
|
RplTraceClass IRCMessageType = 209
|
||||||
|
RplStatsLinkInfo IRCMessageType = 211
|
||||||
|
RplStatsCommands IRCMessageType = 212
|
||||||
|
RplStatsCLine IRCMessageType = 213
|
||||||
|
RplStatsNLine IRCMessageType = 214
|
||||||
|
RplStatsILine IRCMessageType = 215
|
||||||
|
RplStatsKLine IRCMessageType = 216
|
||||||
|
RplStatsQLine IRCMessageType = 217
|
||||||
|
RplStatsYLine IRCMessageType = 218
|
||||||
|
RplEndOfStats IRCMessageType = 219
|
||||||
|
|
||||||
|
RplUmodeIs IRCMessageType = 221
|
||||||
|
RplServList IRCMessageType = 234
|
||||||
|
RplServListEnd IRCMessageType = 235
|
||||||
|
RplStatsLLine IRCMessageType = 241
|
||||||
|
RplStatsUptime IRCMessageType = 242
|
||||||
|
RplStatsOLine IRCMessageType = 243
|
||||||
|
RplStatsHLine IRCMessageType = 244
|
||||||
|
RplLuserClient IRCMessageType = 251
|
||||||
|
RplLuserOp IRCMessageType = 252
|
||||||
|
RplLuserUnknown IRCMessageType = 253
|
||||||
|
|
||||||
|
RplLuserChannels IRCMessageType = 254
|
||||||
|
RplLuserMe IRCMessageType = 255
|
||||||
|
RplAdminMe IRCMessageType = 256
|
||||||
|
RplAdminLoc1 IRCMessageType = 257
|
||||||
|
RplAdminLoc2 IRCMessageType = 258
|
||||||
|
RplAdminEmail IRCMessageType = 259
|
||||||
|
RplTraceLog IRCMessageType = 261
|
||||||
|
RplTraceEnd IRCMessageType = 262
|
||||||
|
RplTryAgain IRCMessageType = 263
|
||||||
|
|
||||||
|
RplAway IRCMessageType = 301
|
||||||
|
RplUserHost IRCMessageType = 302
|
||||||
|
RplIson IRCMessageType = 303
|
||||||
|
RplUnaway IRCMessageType = 305
|
||||||
|
RplNowAway IRCMessageType = 306
|
||||||
|
RplWhoisUser IRCMessageType = 311
|
||||||
|
RplWhoisServer IRCMessageType = 312
|
||||||
|
RplWhoisOperator IRCMessageType = 313
|
||||||
|
RplWhoWasUser IRCMessageType = 314
|
||||||
|
RplEndOfWho IRCMessageType = 315
|
||||||
|
RplWhoisIdle IRCMessageType = 317
|
||||||
|
RplEndOfWhois IRCMessageType = 318
|
||||||
|
RplWhoisChannels IRCMessageType = 319
|
||||||
|
RplListStart IRCMessageType = 321
|
||||||
|
RplList IRCMessageType = 322
|
||||||
|
RplListEnd IRCMessageType = 323
|
||||||
|
RplChannelModeIs IRCMessageType = 324
|
||||||
|
|
||||||
|
RplUniqOpIs IRCMessageType = 325
|
||||||
|
RplCreationTime IRCMessageType = 329
|
||||||
|
RplNoTopic IRCMessageType = 331
|
||||||
|
RplTopic IRCMessageType = 332
|
||||||
|
RplTopicWhoTime IRCMessageType = 333
|
||||||
|
RplWhoisActually IRCMessageType = 338
|
||||||
|
RplInviting IRCMessageType = 341
|
||||||
|
RplSummoning IRCMessageType = 342
|
||||||
|
RplInviteList IRCMessageType = 346
|
||||||
|
RplEndOfInviteList IRCMessageType = 347
|
||||||
|
RplExceptList IRCMessageType = 348
|
||||||
|
RplEndOfExceptList IRCMessageType = 349
|
||||||
|
RplVersion IRCMessageType = 351
|
||||||
|
RplWhoReply IRCMessageType = 352
|
||||||
|
RplNamReply IRCMessageType = 353
|
||||||
|
RplLinks IRCMessageType = 364
|
||||||
|
RplEndOfLinks IRCMessageType = 365
|
||||||
|
RplEndOfNames IRCMessageType = 366
|
||||||
|
RplBanList IRCMessageType = 367
|
||||||
|
RplEndOfBanList IRCMessageType = 368
|
||||||
|
RplEndOfWhowas IRCMessageType = 369
|
||||||
|
RplInfo IRCMessageType = 371
|
||||||
|
RplMotd IRCMessageType = 372
|
||||||
|
RplEndOfInfo IRCMessageType = 374
|
||||||
|
RplMotdStart IRCMessageType = 375
|
||||||
|
RplEndOfMotd IRCMessageType = 376
|
||||||
|
RplYoureOper IRCMessageType = 381
|
||||||
|
RplRehashing IRCMessageType = 382
|
||||||
|
RplYoureService IRCMessageType = 383
|
||||||
|
RplTime IRCMessageType = 391
|
||||||
|
RplUsersStart IRCMessageType = 392
|
||||||
|
RplUsers IRCMessageType = 393
|
||||||
|
RplEndOfUsers IRCMessageType = 394
|
||||||
|
RplNoUsers IRCMessageType = 395
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error replies (400-599).
|
||||||
|
const (
|
||||||
|
ErrNoSuchNick IRCMessageType = 401
|
||||||
|
ErrNoSuchServer IRCMessageType = 402
|
||||||
|
ErrNoSuchChannel IRCMessageType = 403
|
||||||
|
ErrCannotSendToChan IRCMessageType = 404
|
||||||
|
ErrTooManyChannels IRCMessageType = 405
|
||||||
|
ErrWasNoSuchNick IRCMessageType = 406
|
||||||
|
ErrTooManyTargets IRCMessageType = 407
|
||||||
|
ErrNoSuchService IRCMessageType = 408
|
||||||
|
ErrNoOrigin IRCMessageType = 409
|
||||||
|
ErrNoRecipient IRCMessageType = 411
|
||||||
|
ErrNoTextToSend IRCMessageType = 412
|
||||||
|
ErrNoTopLevel IRCMessageType = 413
|
||||||
|
ErrWildTopLevel IRCMessageType = 414
|
||||||
|
ErrBadMask IRCMessageType = 415
|
||||||
|
ErrUnknownCommand IRCMessageType = 421
|
||||||
|
ErrNoMotd IRCMessageType = 422
|
||||||
|
ErrNoAdminInfo IRCMessageType = 423
|
||||||
|
ErrFileError IRCMessageType = 424
|
||||||
|
ErrNoNicknameGiven IRCMessageType = 431
|
||||||
|
ErrErroneusNickname IRCMessageType = 432
|
||||||
|
ErrNicknameInUse IRCMessageType = 433
|
||||||
|
ErrNickCollision IRCMessageType = 436
|
||||||
|
ErrUnavailResource IRCMessageType = 437
|
||||||
|
|
||||||
|
ErrUserNotInChannel IRCMessageType = 441
|
||||||
|
ErrNotOnChannel IRCMessageType = 442
|
||||||
|
ErrUserOnChannel IRCMessageType = 443
|
||||||
|
ErrNoLogin IRCMessageType = 444
|
||||||
|
ErrSummonDisabled IRCMessageType = 445
|
||||||
|
ErrUsersDisabled IRCMessageType = 446
|
||||||
|
ErrNotRegistered IRCMessageType = 451
|
||||||
|
ErrNeedMoreParams IRCMessageType = 461
|
||||||
|
ErrAlreadyRegistered IRCMessageType = 462
|
||||||
|
ErrNoPermForHost IRCMessageType = 463
|
||||||
|
ErrPasswdMismatch IRCMessageType = 464
|
||||||
|
ErrYoureBannedCreep IRCMessageType = 465
|
||||||
|
ErrYouWillBeBanned IRCMessageType = 466
|
||||||
|
ErrKeySet IRCMessageType = 467
|
||||||
|
ErrChannelIsFull IRCMessageType = 471
|
||||||
|
ErrUnknownMode IRCMessageType = 472
|
||||||
|
ErrInviteOnlyChan IRCMessageType = 473
|
||||||
|
ErrBannedFromChan IRCMessageType = 474
|
||||||
|
ErrBadChannelKey IRCMessageType = 475
|
||||||
|
ErrBadChanMask IRCMessageType = 476
|
||||||
|
ErrNoChanModes IRCMessageType = 477
|
||||||
|
ErrBanListFull IRCMessageType = 478
|
||||||
|
ErrNoPrivileges IRCMessageType = 481
|
||||||
|
ErrChanOpPrivsNeeded IRCMessageType = 482
|
||||||
|
ErrCantKillServer IRCMessageType = 483
|
||||||
|
ErrRestricted IRCMessageType = 484
|
||||||
|
ErrUniqOpPrivsNeeded IRCMessageType = 485
|
||||||
|
ErrNoOperHost IRCMessageType = 491
|
||||||
|
|
||||||
|
ErrUmodeUnknownFlag IRCMessageType = 501
|
||||||
|
ErrUsersDoNotMatch IRCMessageType = 502
|
||||||
|
)
|
||||||
|
|
||||||
|
// names maps numeric codes to their standard IRC names.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals
|
||||||
|
var names = map[IRCMessageType]string{
|
||||||
|
RplWelcome: "RPL_WELCOME",
|
||||||
|
RplYourHost: "RPL_YOURHOST",
|
||||||
|
RplCreated: "RPL_CREATED",
|
||||||
|
RplMyInfo: "RPL_MYINFO",
|
||||||
|
RplBounce: "RPL_BOUNCE",
|
||||||
|
|
||||||
|
RplTraceLink: "RPL_TRACELINK",
|
||||||
|
RplTraceConnecting: "RPL_TRACECONNECTING",
|
||||||
|
RplTraceHandshake: "RPL_TRACEHANDSHAKE",
|
||||||
|
RplTraceUnknown: "RPL_TRACEUNKNOWN",
|
||||||
|
RplTraceOperator: "RPL_TRACEOPERATOR",
|
||||||
|
RplTraceUser: "RPL_TRACEUSER",
|
||||||
|
RplTraceServer: "RPL_TRACESERVER",
|
||||||
|
RplTraceService: "RPL_TRACESERVICE",
|
||||||
|
RplTraceNewType: "RPL_TRACENEWTYPE",
|
||||||
|
RplTraceClass: "RPL_TRACECLASS",
|
||||||
|
RplStatsLinkInfo: "RPL_STATSLINKINFO",
|
||||||
|
RplStatsCommands: "RPL_STATSCOMMANDS",
|
||||||
|
RplStatsCLine: "RPL_STATSCLINE",
|
||||||
|
RplStatsNLine: "RPL_STATSNLINE",
|
||||||
|
RplStatsILine: "RPL_STATSILINE",
|
||||||
|
RplStatsKLine: "RPL_STATSKLINE",
|
||||||
|
RplStatsQLine: "RPL_STATSQLINE",
|
||||||
|
RplStatsYLine: "RPL_STATSYLINE",
|
||||||
|
RplEndOfStats: "RPL_ENDOFSTATS",
|
||||||
|
|
||||||
|
RplUmodeIs: "RPL_UMODEIS",
|
||||||
|
RplServList: "RPL_SERVLIST",
|
||||||
|
RplServListEnd: "RPL_SERVLISTEND",
|
||||||
|
RplStatsLLine: "RPL_STATSLLINE",
|
||||||
|
RplStatsUptime: "RPL_STATSUPTIME",
|
||||||
|
RplStatsOLine: "RPL_STATSOLINE",
|
||||||
|
RplStatsHLine: "RPL_STATSHLINE",
|
||||||
|
RplLuserClient: "RPL_LUSERCLIENT",
|
||||||
|
RplLuserOp: "RPL_LUSEROP",
|
||||||
|
RplLuserUnknown: "RPL_LUSERUNKNOWN",
|
||||||
|
|
||||||
|
RplLuserChannels: "RPL_LUSERCHANNELS",
|
||||||
|
RplLuserMe: "RPL_LUSERME",
|
||||||
|
RplAdminMe: "RPL_ADMINME",
|
||||||
|
RplAdminLoc1: "RPL_ADMINLOC1",
|
||||||
|
RplAdminLoc2: "RPL_ADMINLOC2",
|
||||||
|
RplAdminEmail: "RPL_ADMINEMAIL",
|
||||||
|
RplTraceLog: "RPL_TRACELOG",
|
||||||
|
RplTraceEnd: "RPL_TRACEEND",
|
||||||
|
RplTryAgain: "RPL_TRYAGAIN",
|
||||||
|
|
||||||
|
RplAway: "RPL_AWAY",
|
||||||
|
RplUserHost: "RPL_USERHOST",
|
||||||
|
RplIson: "RPL_ISON",
|
||||||
|
RplUnaway: "RPL_UNAWAY",
|
||||||
|
RplNowAway: "RPL_NOWAWAY",
|
||||||
|
RplWhoisUser: "RPL_WHOISUSER",
|
||||||
|
RplWhoisServer: "RPL_WHOISSERVER",
|
||||||
|
RplWhoisOperator: "RPL_WHOISOPERATOR",
|
||||||
|
RplWhoWasUser: "RPL_WHOWASUSER",
|
||||||
|
RplEndOfWho: "RPL_ENDOFWHO",
|
||||||
|
RplWhoisIdle: "RPL_WHOISIDLE",
|
||||||
|
RplEndOfWhois: "RPL_ENDOFWHOIS",
|
||||||
|
RplWhoisChannels: "RPL_WHOISCHANNELS",
|
||||||
|
RplListStart: "RPL_LISTSTART",
|
||||||
|
RplList: "RPL_LIST",
|
||||||
|
RplListEnd: "RPL_LISTEND", //nolint:misspell
|
||||||
|
RplChannelModeIs: "RPL_CHANNELMODEIS",
|
||||||
|
|
||||||
|
RplUniqOpIs: "RPL_UNIQOPIS",
|
||||||
|
RplCreationTime: "RPL_CREATIONTIME",
|
||||||
|
RplNoTopic: "RPL_NOTOPIC",
|
||||||
|
RplTopic: "RPL_TOPIC",
|
||||||
|
RplTopicWhoTime: "RPL_TOPICWHOTIME",
|
||||||
|
RplWhoisActually: "RPL_WHOISACTUALLY",
|
||||||
|
RplInviting: "RPL_INVITING",
|
||||||
|
RplSummoning: "RPL_SUMMONING",
|
||||||
|
RplInviteList: "RPL_INVITELIST",
|
||||||
|
RplEndOfInviteList: "RPL_ENDOFINVITELIST",
|
||||||
|
RplExceptList: "RPL_EXCEPTLIST",
|
||||||
|
RplEndOfExceptList: "RPL_ENDOFEXCEPTLIST",
|
||||||
|
RplVersion: "RPL_VERSION",
|
||||||
|
RplWhoReply: "RPL_WHOREPLY",
|
||||||
|
RplNamReply: "RPL_NAMREPLY",
|
||||||
|
RplLinks: "RPL_LINKS",
|
||||||
|
RplEndOfLinks: "RPL_ENDOFLINKS",
|
||||||
|
RplEndOfNames: "RPL_ENDOFNAMES",
|
||||||
|
RplBanList: "RPL_BANLIST",
|
||||||
|
RplEndOfBanList: "RPL_ENDOFBANLIST",
|
||||||
|
RplEndOfWhowas: "RPL_ENDOFWHOWAS",
|
||||||
|
RplInfo: "RPL_INFO",
|
||||||
|
RplMotd: "RPL_MOTD",
|
||||||
|
RplEndOfInfo: "RPL_ENDOFINFO",
|
||||||
|
RplMotdStart: "RPL_MOTDSTART",
|
||||||
|
RplEndOfMotd: "RPL_ENDOFMOTD",
|
||||||
|
RplYoureOper: "RPL_YOUREOPER",
|
||||||
|
RplRehashing: "RPL_REHASHING",
|
||||||
|
RplYoureService: "RPL_YOURESERVICE",
|
||||||
|
RplTime: "RPL_TIME",
|
||||||
|
RplUsersStart: "RPL_USERSSTART",
|
||||||
|
RplUsers: "RPL_USERS",
|
||||||
|
RplEndOfUsers: "RPL_ENDOFUSERS",
|
||||||
|
RplNoUsers: "RPL_NOUSERS",
|
||||||
|
|
||||||
|
ErrNoSuchNick: "ERR_NOSUCHNICK",
|
||||||
|
ErrNoSuchServer: "ERR_NOSUCHSERVER",
|
||||||
|
ErrNoSuchChannel: "ERR_NOSUCHCHANNEL",
|
||||||
|
ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN",
|
||||||
|
ErrTooManyChannels: "ERR_TOOMANYCHANNELS",
|
||||||
|
ErrWasNoSuchNick: "ERR_WASNOSUCHNICK",
|
||||||
|
ErrTooManyTargets: "ERR_TOOMANYTARGETS",
|
||||||
|
ErrNoSuchService: "ERR_NOSUCHSERVICE",
|
||||||
|
ErrNoOrigin: "ERR_NOORIGIN",
|
||||||
|
ErrNoRecipient: "ERR_NORECIPIENT",
|
||||||
|
ErrNoTextToSend: "ERR_NOTEXTTOSEND",
|
||||||
|
ErrNoTopLevel: "ERR_NOTOPLEVEL",
|
||||||
|
ErrWildTopLevel: "ERR_WILDTOPLEVEL",
|
||||||
|
ErrBadMask: "ERR_BADMASK",
|
||||||
|
ErrUnknownCommand: "ERR_UNKNOWNCOMMAND",
|
||||||
|
ErrNoMotd: "ERR_NOMOTD",
|
||||||
|
ErrNoAdminInfo: "ERR_NOADMININFO",
|
||||||
|
ErrFileError: "ERR_FILEERROR",
|
||||||
|
ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN",
|
||||||
|
ErrErroneusNickname: "ERR_ERRONEUSNICKNAME",
|
||||||
|
ErrNicknameInUse: "ERR_NICKNAMEINUSE",
|
||||||
|
ErrNickCollision: "ERR_NICKCOLLISION",
|
||||||
|
ErrUnavailResource: "ERR_UNAVAILRESOURCE",
|
||||||
|
|
||||||
|
ErrUserNotInChannel: "ERR_USERNOTINCHANNEL",
|
||||||
|
ErrNotOnChannel: "ERR_NOTONCHANNEL",
|
||||||
|
ErrUserOnChannel: "ERR_USERONCHANNEL",
|
||||||
|
ErrNoLogin: "ERR_NOLOGIN",
|
||||||
|
ErrSummonDisabled: "ERR_SUMMONDISABLED",
|
||||||
|
ErrUsersDisabled: "ERR_USERSDISABLED",
|
||||||
|
ErrNotRegistered: "ERR_NOTREGISTERED",
|
||||||
|
ErrNeedMoreParams: "ERR_NEEDMOREPARAMS",
|
||||||
|
ErrAlreadyRegistered: "ERR_ALREADYREGISTERED",
|
||||||
|
ErrNoPermForHost: "ERR_NOPERMFORHOST",
|
||||||
|
ErrPasswdMismatch: "ERR_PASSWDMISMATCH",
|
||||||
|
ErrYoureBannedCreep: "ERR_YOUREBANNEDCREEP",
|
||||||
|
ErrYouWillBeBanned: "ERR_YOUWILLBEBANNED",
|
||||||
|
ErrKeySet: "ERR_KEYSET",
|
||||||
|
ErrChannelIsFull: "ERR_CHANNELISFULL",
|
||||||
|
ErrUnknownMode: "ERR_UNKNOWNMODE",
|
||||||
|
ErrInviteOnlyChan: "ERR_INVITEONLYCHAN",
|
||||||
|
ErrBannedFromChan: "ERR_BANNEDFROMCHAN",
|
||||||
|
ErrBadChannelKey: "ERR_BADCHANNELKEY",
|
||||||
|
ErrBadChanMask: "ERR_BADCHANMASK",
|
||||||
|
ErrNoChanModes: "ERR_NOCHANMODES",
|
||||||
|
ErrBanListFull: "ERR_BANLISTFULL",
|
||||||
|
ErrNoPrivileges: "ERR_NOPRIVILEGES",
|
||||||
|
ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED",
|
||||||
|
ErrCantKillServer: "ERR_CANTKILLSERVER",
|
||||||
|
ErrRestricted: "ERR_RESTRICTED",
|
||||||
|
ErrUniqOpPrivsNeeded: "ERR_UNIQOPPRIVSNEEDED",
|
||||||
|
ErrNoOperHost: "ERR_NOOPERHOST",
|
||||||
|
|
||||||
|
ErrUmodeUnknownFlag: "ERR_UMODEUNKNOWNFLAG",
|
||||||
|
ErrUsersDoNotMatch: "ERR_USERSDONTMATCH",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the standard IRC name for a numeric code
|
||||||
|
// (e.g., Name(2) returns "RPL_YOURHOST"). Returns an
|
||||||
|
// empty string if the code is unknown.
|
||||||
|
//
|
||||||
|
// Deprecated: Use IRCMessageType.Name() instead.
|
||||||
|
func Name(code IRCMessageType) string {
|
||||||
|
return names[code]
|
||||||
|
}
|
||||||
163
pkg/irc/numerics_test.go
Normal file
163
pkg/irc/numerics_test.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package irc_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
numeric irc.IRCMessageType
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{irc.RplWelcome, "RPL_WELCOME"},
|
||||||
|
{irc.RplBounce, "RPL_BOUNCE"},
|
||||||
|
{irc.RplLuserOp, "RPL_LUSEROP"},
|
||||||
|
{irc.ErrCannotSendToChan, "ERR_CANNOTSENDTOCHAN"},
|
||||||
|
{irc.ErrNicknameInUse, "ERR_NICKNAMEINUSE"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
if got := tc.numeric.Name(); got != tc.want {
|
||||||
|
t.Errorf("IRCMessageType(%d).Name() = %q, want %q", tc.numeric.Int(), got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestString(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
numeric irc.IRCMessageType
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{irc.RplWelcome, "RPL_WELCOME <001>"},
|
||||||
|
{irc.RplBounce, "RPL_BOUNCE <005>"},
|
||||||
|
{irc.RplLuserOp, "RPL_LUSEROP <252>"},
|
||||||
|
{irc.ErrCannotSendToChan, "ERR_CANNOTSENDTOCHAN <404>"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
if got := tc.numeric.String(); got != tc.want {
|
||||||
|
t.Errorf("IRCMessageType(%d).String() = %q, want %q", tc.numeric.Int(), got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
numeric irc.IRCMessageType
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{irc.RplWelcome, "001"},
|
||||||
|
{irc.RplBounce, "005"},
|
||||||
|
{irc.RplLuserOp, "252"},
|
||||||
|
{irc.ErrCannotSendToChan, "404"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
if got := tc.numeric.Code(); got != tc.want {
|
||||||
|
t.Errorf("IRCMessageType(%d).Code() = %q, want %q", tc.numeric.Int(), got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
numeric irc.IRCMessageType
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{irc.RplWelcome, 1},
|
||||||
|
{irc.RplBounce, 5},
|
||||||
|
{irc.RplLuserOp, 252},
|
||||||
|
{irc.ErrCannotSendToChan, 404},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
if got := tc.numeric.Int(); got != tc.want {
|
||||||
|
t.Errorf("IRCMessageType(%d).Int() = %d, want %d", tc.want, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromInt_Known(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
code int
|
||||||
|
want irc.IRCMessageType
|
||||||
|
}{
|
||||||
|
{1, irc.RplWelcome},
|
||||||
|
{5, irc.RplBounce},
|
||||||
|
{252, irc.RplLuserOp},
|
||||||
|
{404, irc.ErrCannotSendToChan},
|
||||||
|
{433, irc.ErrNicknameInUse},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
got, err := irc.FromInt(test.code)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("FromInt(%d) returned unexpected error: %v", test.code, err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != test.want {
|
||||||
|
t.Errorf("FromInt(%d) = %v, want %v", test.code, got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromInt_Unknown(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
unknowns := []int{0, 999, 600, -1}
|
||||||
|
for _, code := range unknowns {
|
||||||
|
_, err := irc.FromInt(code)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("FromInt(%d) expected error, got nil", code)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, irc.ErrUnknownNumeric) {
|
||||||
|
t.Errorf("FromInt(%d) error = %v, want ErrUnknownNumeric", code, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownNumeric_Name(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
unknown := irc.IRCMessageType(999)
|
||||||
|
if got := unknown.Name(); got != "" {
|
||||||
|
t.Errorf("IRCMessageType(999).Name() = %q, want empty string", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownNumeric_String(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
unknown := irc.IRCMessageType(999)
|
||||||
|
want := "UNKNOWN <999>"
|
||||||
|
|
||||||
|
if got := unknown.String(); got != want {
|
||||||
|
t.Errorf("IRCMessageType(999).String() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeprecatedNameFunc(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got := irc.Name(irc.RplYourHost); got != "RPL_YOURHOST" {
|
||||||
|
t.Errorf("Name(RplYourHost) = %q, want %q", got, "RPL_YOURHOST")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Message Schemas
|
# Message Schemas
|
||||||
|
|
||||||
JSON Schema definitions (draft 2020-12) for the chat protocol. Messages use
|
JSON Schema definitions (draft 2020-12) for the neoirc protocol. Messages use
|
||||||
**IRC command names and numeric reply codes** (RFC 1459/2812) encoded as JSON
|
**IRC command names and numeric reply codes** (RFC 1459/2812) encoded as JSON
|
||||||
over HTTP.
|
over HTTP.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/JOIN.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/JOIN.json",
|
||||||
"title": "JOIN",
|
"title": "JOIN",
|
||||||
"description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.",
|
"description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/KICK.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/KICK.json",
|
||||||
"title": "KICK",
|
"title": "KICK",
|
||||||
"description": "Kick a user from a channel. RFC 1459 §4.2.8.",
|
"description": "Kick a user from a channel. RFC 1459 §4.2.8.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/MODE.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/MODE.json",
|
||||||
"title": "MODE",
|
"title": "MODE",
|
||||||
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
|
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/NICK.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/NICK.json",
|
||||||
"title": "NICK",
|
"title": "NICK",
|
||||||
"description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.",
|
"description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/NOTICE.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/NOTICE.json",
|
||||||
"title": "NOTICE",
|
"title": "NOTICE",
|
||||||
"description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.",
|
"description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PART.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PART.json",
|
||||||
"title": "PART",
|
"title": "PART",
|
||||||
"description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.",
|
"description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PING.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PING.json",
|
||||||
"title": "PING",
|
"title": "PING",
|
||||||
"description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.",
|
"description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PONG.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PONG.json",
|
||||||
"title": "PONG",
|
"title": "PONG",
|
||||||
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
|
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PRIVMSG.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PRIVMSG.json",
|
||||||
"title": "PRIVMSG",
|
"title": "PRIVMSG",
|
||||||
"description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.",
|
"description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PUBKEY.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PUBKEY.json",
|
||||||
"title": "PUBKEY",
|
"title": "PUBKEY",
|
||||||
"description": "Announce or relay a user's public signing key. C2S: client announces key to channel or server. S2C: server relays to channel members. Protocol extension (not in RFC 1459). Body is a structured object (not an array) containing the key material.",
|
"description": "Announce or relay a user's public signing key. C2S: client announces key to channel or server. S2C: server relays to channel members. Protocol extension (not in RFC 1459). Body is a structured object (not an array) containing the key material.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/QUIT.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/QUIT.json",
|
||||||
"title": "QUIT",
|
"title": "QUIT",
|
||||||
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
|
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/TOPIC.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/TOPIC.json",
|
||||||
"title": "TOPIC",
|
"title": "TOPIC",
|
||||||
"description": "Get or set channel topic. C2S: set topic (body present) or query (body absent). S2C: topic change notification. RFC 1459 §4.2.4.",
|
"description": "Get or set channel topic. C2S: set topic (body present) or query (body absent). S2C: topic change notification. RFC 1459 §4.2.4.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
@@ -17,6 +17,6 @@
|
|||||||
},
|
},
|
||||||
"required": ["command", "to"],
|
"required": ["command", "to"],
|
||||||
"examples": [
|
"examples": [
|
||||||
{ "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the chat"] }
|
{ "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the channel"] }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/message.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/message.json",
|
||||||
"title": "IRC Message Envelope",
|
"title": "IRC Message Envelope",
|
||||||
"description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).",
|
"description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/001.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/001.json",
|
||||||
"title": "001 RPL_WELCOME",
|
"title": "001 RPL_WELCOME",
|
||||||
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
|
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/002.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/002.json",
|
||||||
"title": "002 RPL_YOURHOST",
|
"title": "002 RPL_YOURHOST",
|
||||||
"description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
|
"description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"command": "002",
|
"command": "002",
|
||||||
"to": "alice",
|
"to": "alice",
|
||||||
"body": [
|
"body": [
|
||||||
"Your host is chat.example.com, running version 0.1.0"
|
"Your host is neoirc.example.com, running version 0.1.0"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/003.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/003.json",
|
||||||
"title": "003 RPL_CREATED",
|
"title": "003 RPL_CREATED",
|
||||||
"description": "Server creation date. RFC 2812 \u00a75.1.",
|
"description": "Server creation date. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/004.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/004.json",
|
||||||
"title": "004 RPL_MYINFO",
|
"title": "004 RPL_MYINFO",
|
||||||
"description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.",
|
"description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"command": "004",
|
"command": "004",
|
||||||
"to": "alice",
|
"to": "alice",
|
||||||
"params": [
|
"params": [
|
||||||
"chat.example.com",
|
"neoirc.example.com",
|
||||||
"0.1.0",
|
"0.1.0",
|
||||||
"o",
|
"o",
|
||||||
"imnst+ov"
|
"imnst+ov"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/322.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/322.json",
|
||||||
"title": "322 RPL_LIST",
|
"title": "322 RPL_LIST",
|
||||||
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
|
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/323.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/323.json",
|
||||||
"title": "323 RPL_LISTEND",
|
"title": "323 RPL_LISTEND",
|
||||||
"description": "End of channel list. RFC 1459 \u00a76.2.",
|
"description": "End of channel list. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/332.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/332.json",
|
||||||
"title": "332 RPL_TOPIC",
|
"title": "332 RPL_TOPIC",
|
||||||
"description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.",
|
"description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
"#general"
|
"#general"
|
||||||
],
|
],
|
||||||
"body": [
|
"body": [
|
||||||
"Welcome to the chat"
|
"Welcome to the channel"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/353.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/353.json",
|
||||||
"title": "353 RPL_NAMREPLY",
|
"title": "353 RPL_NAMREPLY",
|
||||||
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
|
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/366.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/366.json",
|
||||||
"title": "366 RPL_ENDOFNAMES",
|
"title": "366 RPL_ENDOFNAMES",
|
||||||
"description": "End of NAMES list. RFC 1459 \u00a76.2.",
|
"description": "End of NAMES list. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/372.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/372.json",
|
||||||
"title": "372 RPL_MOTD",
|
"title": "372 RPL_MOTD",
|
||||||
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
|
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/375.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/375.json",
|
||||||
"title": "375 RPL_MOTDSTART",
|
"title": "375 RPL_MOTDSTART",
|
||||||
"description": "Start of MOTD. RFC 2812 \u00a75.1.",
|
"description": "Start of MOTD. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/376.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/376.json",
|
||||||
"title": "376 RPL_ENDOFMOTD",
|
"title": "376 RPL_ENDOFMOTD",
|
||||||
"description": "End of MOTD. RFC 2812 \u00a75.1.",
|
"description": "End of MOTD. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/401.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/401.json",
|
||||||
"title": "401 ERR_NOSUCHNICK",
|
"title": "401 ERR_NOSUCHNICK",
|
||||||
"description": "No such nick/channel. RFC 1459 \u00a76.1.",
|
"description": "No such nick/channel. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/403.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/403.json",
|
||||||
"title": "403 ERR_NOSUCHCHANNEL",
|
"title": "403 ERR_NOSUCHCHANNEL",
|
||||||
"description": "No such channel. RFC 1459 \u00a76.1.",
|
"description": "No such channel. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/433.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/433.json",
|
||||||
"title": "433 ERR_NICKNAMEINUSE",
|
"title": "433 ERR_NICKNAMEINUSE",
|
||||||
"description": "Nickname is already in use. RFC 1459 \u00a76.1.",
|
"description": "Nickname is already in use. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/442.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/442.json",
|
||||||
"title": "442 ERR_NOTONCHANNEL",
|
"title": "442 ERR_NOTONCHANNEL",
|
||||||
"description": "You're not on that channel. RFC 1459 \u00a76.1.",
|
"description": "You're not on that channel. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/482.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/482.json",
|
||||||
"title": "482 ERR_CHANOPRIVSNEEDED",
|
"title": "482 ERR_CHANOPRIVSNEEDED",
|
||||||
"description": "You're not channel operator. RFC 1459 \u00a76.1.",
|
"description": "You're not channel operator. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
12
web/build.sh
12
web/build.sh
@@ -16,19 +16,11 @@ fi
|
|||||||
|
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
|
|
||||||
# Build JS bundle
|
# Build JS bundle — preact must be bundled (no CDN/external loader)
|
||||||
${NPX:+$NPX} esbuild src/app.jsx \
|
|
||||||
--bundle \
|
|
||||||
--minify \
|
|
||||||
--jsx-factory=h \
|
|
||||||
--jsx-fragment=Fragment \
|
|
||||||
--define:process.env.NODE_ENV=\"production\" \
|
|
||||||
--external:preact \
|
|
||||||
--outfile=dist/app.js \
|
|
||||||
2>/dev/null || \
|
|
||||||
${NPX:+$NPX} esbuild src/app.jsx \
|
${NPX:+$NPX} esbuild src/app.jsx \
|
||||||
--bundle \
|
--bundle \
|
||||||
--minify \
|
--minify \
|
||||||
|
--format=esm \
|
||||||
--jsx-factory=h \
|
--jsx-factory=h \
|
||||||
--jsx-fragment=Fragment \
|
--jsx-fragment=Fragment \
|
||||||
--define:process.env.NODE_ENV=\"production\" \
|
--define:process.env.NODE_ENV=\"production\" \
|
||||||
|
|||||||
2
web/dist/app.js
vendored
2
web/dist/app.js
vendored
File diff suppressed because one or more lines are too long
13
web/dist/index.html
vendored
13
web/dist/index.html
vendored
@@ -1,13 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Chat</title>
|
|
||||||
<link rel="stylesheet" href="/style.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script src="/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user