diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7fcc6e3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +permissions: + contents: read + +jobs: + test: + name: test (go ${{ matrix.go }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # The module declares go 1.19; test the floor and the latest stable. + go: ['1.19', 'stable'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + - name: go vet + run: go vet ./... + - name: go test + run: go test ./... + - name: go test -race + run: go test -race ./... + + benchmarks: + name: benchmarks compile + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: 'stable' + - name: Compile in-package benchmarks + # -bench='^$' matches no benchmark, so nothing runs, but the test binary + # (including every Benchmark* function) is still compiled. + run: go test -run='^$' -bench='^$' ./... + - name: Vet and compile cross-library benchmarks + working-directory: benchmarks + run: | + go vet ./... + go test -run='^$' -bench='^$' ./... + + autobahn: + name: autobahn conformance + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run Autobahn fuzzing client + working-directory: autobahn + run: docker compose up --build --abort-on-container-exit + - name: Check conformance report + working-directory: autobahn + run: python3 check_report.py + - name: Upload Autobahn report + if: always() + uses: actions/upload-artifact@v4 + with: + name: autobahn-report + path: autobahn/report/ + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 07e2b73..02d4744 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,10 @@ # Dependency directories (remove the comment below to include it) # vendor/ + +# Generated Autobahn|Testsuite conformance report +autobahn/report/ + +# Python bytecode (e.g. from autobahn/check_report.py) +__pycache__/ +*.pyc diff --git a/GoWest.png b/GoWest.png index d55ee62..be424bf 100644 Binary files a/GoWest.png and b/GoWest.png differ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6657fd4 --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +# gowest developer tasks. Run `make help` for a summary. + +.PHONY: help test race vet bench-compile bench check autobahn autobahn-down clean + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' + +test: ## Run the test suite + go test ./... + +race: ## Run the test suite under the race detector + go test -race ./... + +vet: ## Run go vet + go vet ./... + +bench-compile: ## Compile (but do not run) both benchmark suites + go test -run='^$$' -bench='^$$' ./... + cd benchmarks && go vet ./... && go test -run='^$$' -bench='^$$' ./... + +bench: ## Run the cross-library echo benchmarks + cd benchmarks && go test -run='^$$' -bench=BenchmarkEcho -benchmem -count=6 . + +check: vet test race bench-compile ## Run everything CI runs + +autobahn: ## Run the Autobahn conformance suite (requires Docker) + cd autobahn && docker compose up --build --abort-on-container-exit + @echo "Report written to autobahn/report/index.html" + +autobahn-down: ## Tear down the Autobahn containers + cd autobahn && docker compose down + +clean: ## Remove generated artefacts + rm -rf autobahn/report + go clean ./... diff --git a/README.md b/README.md index cb4abc6..7129f74 100644 --- a/README.md +++ b/README.md @@ -1,129 +1,105 @@ # gowest -A minimal, dependency-free WebSocket library for Go with a modern, context-first API. +**A context-first, concurrency-safe, fast WebSocket library for Go.** -![GoWest](GoWest.png) - -## Overview -**gowest** offers a small, ergonomic API built around a single `Conn` type that is safe to use from multiple goroutines. It is a lightweight alternative to `gorilla/websocket` with: - -* A context-first API — `Read`, `Write` and `Accept` all honour `context.Context` deadlines and cancellation. -* Safe concurrent writes — any number of goroutines may call `Write`; frames never interleave. -* An explicit one-reader rule — at most one goroutine calls `Read` at a time. -* Transparent control-frame handling — ping/pong and close frames are handled for you. -* Origin checking and subprotocol negotiation. -* No external dependencies. - -## Features +[![CI](https://github.com/ystepanoff/gowest/actions/workflows/ci.yml/badge.svg)](https://github.com/ystepanoff/gowest/actions/workflows/ci.yml) +[![Go Reference](https://pkg.go.dev/badge/github.com/ystepanoff/gowest.svg)](https://pkg.go.dev/github.com/ystepanoff/gowest) +[![Go Report Card](https://goreportcard.com/badge/github.com/ystepanoff/gowest)](https://goreportcard.com/report/github.com/ystepanoff/gowest) -- [x] Handshake: `Accept` performs the RFC 6455 upgrade with origin checking. -- [x] Frame Parsing: Reads and writes WebSocket frames in compliance with RFC 6455. -- [x] Binary and Text Frames: Send and receive binary or text messages, including fragments. -- [x] Ping/Pong: Pings are answered automatically; pongs are ignored. -- [x] Close Frames: Proper close handshake with status codes via `Close`. -- [x] Subprotocols: Negotiated from `AcceptOptions.Subprotocols`. -- [ ] Compression: Planned (no permessage-deflate yet). - -## Concurrency contract - -* `Write` may be called concurrently from multiple goroutines; writes are serialised internally. -* `Read` must be called from at most one goroutine at a time. -* `Close` is safe to call concurrently with `Read` and `Write`, and is idempotent. - -## Performance + -On common, non-compressed workloads gowest matches or beats `gorilla/websocket`, -`coder/websocket` and `gobwas/ws`, while allocating an order of magnitude less -per message. Numbers below are a one-message echo round trip over a TCP loopback -connection, median of 10 runs on an Apple M4 Pro (Go 1.24); a single neutral -client drives every server, so the only variable per row is the server library. - -| Payload | gowest | gorilla | coder | gobwas | -| -------------- | ------------ | ---------------- | ---------------- | ---------------- | -| 32 B text | **15.84 µs** | 15.97 µs | 15.85 µs | 17.20 µs (+9%) | -| 1 KiB binary | **15.96 µs** | 16.38 µs (+3%) | 16.73 µs (+5%) | 18.68 µs (+17%) | -| 64 KiB binary | **39.09 µs** | 69.14 µs (+77%) | 70.31 µs (+80%) | 73.44 µs (+88%) | -| 1 MiB binary | **211.3 µs** | 435.3 µs (+106%) | 445.0 µs (+111%) | 482.5 µs (+128%) | -| 10 MiB binary | **1.706 ms** | 2.950 ms (+73%) | 3.187 ms (+87%) | 3.125 ms (+83%) | - -| Payload (allocs/op) | gowest | gorilla | coder | gobwas | -| ------------------- | ------ | ------- | ----- | ------ | -| 32 B text | **2** | 3 | 2 | 6 | -| 1 KiB binary | **3** | 6 | 5 | 9 | -| 1 MiB binary | **3** | 34 | 31 | 35 | -| 10 MiB binary | **3** | 45 | 42 | 46 | - -Small messages are dominated by ~16 µs of loopback latency, so the field ties at -32 B; gowest's framing wins show from 64 KiB upward (~2× faster), and it holds a -constant **3 allocations** (a single payload copy) at every size. `Read` costs -one allocation, `Write` zero. +![GoWest](GoWest.png) -See [`BENCHMARKS.md`](BENCHMARKS.md) for the full methodology, all payload sizes -and how to reproduce the numbers. +gowest is a small WebSocket library built around a single `Conn` type with a +modern, `context`-aware API and an explicit concurrency contract. It is a +**server-side** library (RFC 6455) with **no external dependencies**. + +- **Context-first** — `Accept`, `Read`, `Write` and `Ping` all honour + `context.Context` deadlines and cancellation. +- **Concurrency-safe** — many goroutines may `Write` at once; frames never + interleave. The contract is explicit and documented below. +- **Fast** — one allocation per `Read`, zero per `Write`, and ~2× the throughput + of gorilla/coder/gobwas on uncompressed payloads from 64 KiB up + ([benchmarks](#performance)). +- **Correct** — transparent ping/pong and close handling, UTF-8 validation, and + framing-protocol enforcement, with an [Autobahn](autobahn/) harness to prove + it. +- **Dependency-free** — only the standard library. + +> **Status: beta.** The API is stable and the test suite (including `-race`) is +> green, but gowest has not yet been validated in production deployments. See +> [Production readiness](#production-readiness) for the honest details. ## Installation + ```bash go get github.com/ystepanoff/gowest@latest ``` -Then import it in your Go code: ```go -import ( - "github.com/ystepanoff/gowest" -) +import "github.com/ystepanoff/gowest" ``` -## Basic usage +Requires Go 1.19+. + +## Quick start (server) + +gowest is **server-only today** — there is no `Dial`/client yet (it is on the +[roadmap](#roadmap)). Upgrade an incoming HTTP request with `Accept`, then read +and write messages on the returned `*Conn`: -Below is a simple echo server using the modern `Accept` API. ```go package main import ( - "context" - "log" - "net/http" + "context" + "log" + "net/http" - "github.com/ystepanoff/gowest" + "github.com/ystepanoff/gowest" ) func handler(w http.ResponseWriter, r *http.Request) { - c, err := gowest.Accept(r.Context(), w, r, &gowest.AcceptOptions{ - OriginPatterns: []string{"*"}, // allow any origin; tighten in production - }) - if err != nil { - log.Println("accept:", err) - return - } - defer c.Close(gowest.StatusInternalError, "") - - ctx := context.Background() - for { - typ, data, err := c.Read(ctx) - if err != nil { - log.Println("read:", err) // *gowest.CloseError on a clean close - return - } - if err := c.Write(ctx, typ, data); err != nil { - log.Println("write:", err) - return - } - } + c, err := gowest.Accept(r.Context(), w, r, &gowest.AcceptOptions{ + OriginPatterns: []string{"*"}, // allow any origin; tighten in production + }) + if err != nil { + log.Println("accept:", err) + return + } + defer c.Close(gowest.StatusInternalError, "") + + ctx := context.Background() + for { + typ, data, err := c.Read(ctx) + if err != nil { + return // *gowest.CloseError on a clean peer close + } + if err := c.Write(ctx, typ, data); err != nil { + return + } + } } func main() { - http.HandleFunc("/", handler) - log.Println("Server listening on :8080") - log.Fatal(http.ListenAndServe(":8080", nil)) + http.HandleFunc("/", handler) + log.Println("listening on :8080") + log.Fatal(http.ListenAndServe(":8080", nil)) } ``` -A runnable version lives in [`examples/echo`](examples/echo). +A runnable version lives in [`examples/echo`](examples/echo). For a client to +test against, use any browser or an existing client library (e.g. +`coder/websocket`'s `Dial`) until gowest ships its own. -* **Upgrade**: `gowest.Accept` validates the handshake, checks the origin, negotiates a subprotocol and returns a `*Conn`. -* **Read**: `(*Conn).Read` blocks until a complete message is available, handling ping/pong and close frames transparently. Call it from at most one goroutine. -* **Write**: `(*Conn).Write` sends a single message and is safe to call concurrently. -* **Close**: `(*Conn).Close` performs the close handshake with a `StatusCode` and reason. +- **Upgrade** — `Accept` validates the handshake, checks the origin, negotiates + a subprotocol and returns a `*Conn`. +- **Read** — `(*Conn).Read` blocks until a complete message is available, + handling ping/pong and close frames transparently. One goroutine at a time. +- **Write** — `(*Conn).Write` sends one message; safe to call concurrently. +- **Close** — `(*Conn).Close` performs the close handshake with a `StatusCode` + and reason. ### Options @@ -131,24 +107,188 @@ A runnable version lives in [`examples/echo`](examples/echo). | Field | Meaning | | --- | --- | -| `OriginPatterns` | Allowed Origin host patterns (supports a single `*` wildcard). Empty = same-origin only. | +| `OriginPatterns` | Allowed `Origin` host patterns (single `*` wildcard). Empty = same-origin only. | | `Subprotocols` | Server-preferred subprotocols; the first match with the client is negotiated. | | `MaxMessageBytes` | Maximum inbound message size; larger messages fail with `StatusMessageTooBig`. Unset (≤ 0) uses `DefaultMaxMessageBytes` (32 MiB), so connections are bounded by default. | | `ReadBufferSize` / `WriteBufferSize` | Buffer sizes for the hijacked connection. | +## Concurrency guarantees + +gowest follows the same one-reader / many-writer rule used by most production +WebSocket libraries: + +- **`Write`** may be called concurrently from any number of goroutines. Writes + are serialised internally with a mutex, so frames never interleave on the wire. +- **`Read`** must be called from **at most one goroutine at a time**. Reading + from several goroutines concurrently corrupts the message stream and is a + programming error. +- **`Close`** is safe to call concurrently with `Read`, `Write` and `Ping`, and + is idempotent. It unblocks any in-flight operation, which then returns an error. +- **`Ping`** may be called concurrently with `Write`; pongs are observed by the + goroutine calling `Read`. + +Cancellation is implemented with `net.Conn` deadlines, not a per-call goroutine +that outlives the operation: a cancelled or timed-out `Read`/`Write` interrupts +the underlying I/O and fails the connection (a WebSocket stream cannot be safely +resumed mid-frame). The full contract is documented in the +[package docs](https://pkg.go.dev/github.com/ystepanoff/gowest). + +The suite is run under the race detector in CI (`go test -race ./...`). + +## Features + +- [x] RFC 6455 server handshake (`Accept`) with origin checking +- [x] Text and binary messages, including fragmented messages +- [x] Automatic ping/pong replies; optional observation handlers +- [x] Close handshake with status codes and reasons +- [x] Subprotocol negotiation +- [x] Inbound message size limits (memory-exhaustion guard) +- [x] Context deadlines and cancellation on every operation +- [ ] Client dialing (`Dial`) — planned +- [ ] permessage-deflate compression — planned + +## Performance + +On common, non-compressed workloads gowest matches or beats `gorilla/websocket`, +`coder/websocket` and `gobwas/ws`, while allocating an order of magnitude less +per message. Numbers below are a one-message echo round trip over a TCP loopback +connection, median of 10 runs on an Apple M4 Pro (Go 1.24); a single neutral +client drives every server, so the only variable per row is the server library. + +| Payload | gowest | gorilla | coder | gobwas | +| -------------- | ------------ | ---------------- | ---------------- | ---------------- | +| 32 B text | **15.84 µs** | 15.97 µs | 15.85 µs | 17.20 µs (+9%) | +| 1 KiB binary | **15.96 µs** | 16.38 µs (+3%) | 16.73 µs (+5%) | 18.68 µs (+17%) | +| 64 KiB binary | **39.09 µs** | 69.14 µs (+77%) | 70.31 µs (+80%) | 73.44 µs (+88%) | +| 1 MiB binary | **211.3 µs** | 435.3 µs (+106%) | 445.0 µs (+111%) | 482.5 µs (+128%) | +| 10 MiB binary | **1.706 ms** | 2.950 ms (+73%) | 3.187 ms (+87%) | 3.125 ms (+83%) | + +| allocs/op | gowest | gorilla | coder | gobwas | +| -------------- | ------ | ------- | ----- | ------ | +| 32 B text | **2** | 3 | 2 | 6 | +| 1 KiB binary | **3** | 6 | 5 | 9 | +| 1 MiB binary | **3** | 34 | 31 | 35 | +| 10 MiB binary | **3** | 45 | 42 | 46 | + +`Read` costs one allocation (the payload); `Write` costs zero. gowest holds a +constant 3 allocations per echo at every size. + +### Benchmark caveats + +- **Read these as relative, not absolute.** They were taken on one machine + (Apple M4 Pro, Go 1.24) over **loopback TCP**, not a real network. Your + hardware, Go version, OS and network will differ. Reproduce them yourself: + `cd benchmarks && go test -bench=BenchmarkEcho -benchmem -count=10 .` +- **Small messages are latency-bound.** At 32 B–1 KiB a round trip is dominated + by ~16 µs of loopback latency, so all libraries effectively tie there; the + framing differences only emerge from ~64 KiB up. +- **Uncompressed only.** gowest does not implement permessage-deflate, so this + compares the uncompressed path. With compression enabled, gorilla and coder + trade CPU for bandwidth — a different trade-off this table does not cover. +- **gobwas** is driven through its idiomatic `wsutil` helpers (how most apps use + it), not its lower-level manual frame API, which can allocate less with more + caller code. +- The comparison libraries live in a separate `benchmarks/` module, so the + gowest library itself stays dependency-free. + +Full methodology and per-size data: [`BENCHMARKS.md`](BENCHMARKS.md). + +## Comparison + +| | gowest | gorilla/websocket | coder/websocket | +| --- | --- | --- | --- | +| API style | context-first | callback/deadline | context-first | +| Server (`Accept`) | ✅ | ✅ | ✅ | +| Client (`Dial`) | ❌ (planned) | ✅ | ✅ | +| Concurrent writers | ✅ (mutex) | ❌ (one writer) | ✅ | +| permessage-deflate | ❌ (planned) | ✅ | ✅ | +| External dependencies | none | none | none | +| Uncompressed echo throughput (64 KiB+) | fastest in this set | baseline | baseline | +| Allocations per echo | constant (3) | grows with size | grows with size | +| Maintenance | beta, single-maintainer | mature, archived¹ | actively maintained | + +¹ gorilla/websocket is widely deployed but its repository is in maintenance mode. + +If you need a client today, or compression, or a long battle-tested track +record, choose `coder/websocket` or `gorilla/websocket`. Choose gowest when you +want a small, dependency-free, context-first **server** with low per-message +overhead. + +## Conformance (Autobahn) + +gowest ships an [Autobahn|Testsuite](https://github.com/crossbario/autobahn-testsuite) +harness in [`autobahn/`](autobahn/). Because gowest is server-only it runs in +fuzzing-client mode (Autobahn connects to a gowest echo server and drives every +RFC 6455 case). + +**CI runs the full suite on every push** (the `autobahn` job in the workflow) +and **fails the build if any case regresses** — `autobahn/check_report.py` parses +the report and treats anything other than `OK` / `NON-STRICT` / `INFORMATIONAL` / +`UNIMPLEMENTED` as a failure (`wstest` itself always exits 0, so this gate is what +makes the result meaningful). The HTML/JSON report is uploaded as a build +artifact on each run. + +Run the same suite locally with Docker: + +```sh +make autobahn # builds the server, runs the suite, writes autobahn/report/ +``` + +See [`autobahn/README.md`](autobahn/README.md) for details and how to read the +report. + +## Production readiness + +Honest status, so you can make an informed call: + +**What is in place** +- Full unit/integration test suite, green under `go test -race ./...`. +- CI on every push/PR: `go vet`, `go test`, `go test -race`, and benchmark + compilation, on Go 1.19 and latest stable. +- Explicit, tested concurrency contract and context cancellation semantics. +- Inbound size limits on by default (memory-exhaustion guard). +- Benchmarks vs three established libraries. + +**What is not yet proven / missing** +- **No production track record** — not yet known to run real traffic at scale. +- **Autobahn is run and gated in CI** but results have not yet been + independently verified or published outside CI; check the latest `autobahn` + job and its uploaded report for the current status. +- **No client** (`Dial`) and **no compression** (permessage-deflate). +- **No TLS helpers** — terminate `wss://` at your server/proxy. +- Single maintainer; expect the occasional rough edge. + +**Recommendation:** suitable for hobby projects, internal tools and evaluation, +and for production **after** you run the Autobahn suite and load-test for your +workload. Do not treat it as a drop-in, battle-tested replacement for +gorilla/coder yet. + ## Migrating from the legacy API -The original `GetConnection`, `Read` and `WriteString` functions remain available but are **deprecated**. Prefer `Accept` and the `Conn` methods, which add context support, concurrency safety, origin checks and control-frame handling. +The original `GetConnection`, `Read` and `WriteString` functions remain +available but are **deprecated**. Prefer `Accept` and the `Conn` methods, which +add context support, concurrency safety, origin checks and control-frame +handling. + +## Development + +```sh +make help # list targets +make check # vet + test + race + benchmark compile (what CI runs) +make bench # cross-library echo benchmarks +make autobahn # RFC 6455 conformance suite (requires Docker) +``` ## Roadmap -* Compression: per-message deflate. -* Client-side dialing (`Dial`). -* A dedicated writer goroutine as an alternative to the write mutex. -* TLS helpers for `wss://`. +- Client-side dialing (`Dial`) and a published Autobahn report. +- permessage-deflate compression. +- TLS helpers for `wss://`. +- A dedicated writer goroutine as an alternative to the write mutex. ## Contributing -Feel free to suggest new features, open issues, or even pull requests! +Issues and pull requests are welcome — especially production feedback, Autobahn +results on your platform, and benchmark numbers from other hardware. *Happy hacking!* diff --git a/accept.go b/accept.go index e3d4e71..735265d 100644 --- a/accept.go +++ b/accept.go @@ -82,7 +82,10 @@ func Accept(ctx context.Context, w http.ResponseWriter, r *http.Request, opts *A http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return nil, errors.New("gowest: handshake requires GET") } - if !utils.TokenPresentInString(r.Header.Get("Upgrade"), "websocket") { + // RFC 6455 §4.2.1: the Upgrade token must be matched case-insensitively. + // Conformant clients send "websocket", but "WebSocket" is equally valid, so + // use the same case-insensitive token check as the Connection header below. + if !headerContainsToken(r.Header, "Upgrade", "websocket") { http.Error(w, "expected websocket upgrade", http.StatusBadRequest) return nil, errors.New("gowest: missing or invalid Upgrade header") } diff --git a/autobahn/Dockerfile b/autobahn/Dockerfile new file mode 100644 index 0000000..4b90931 --- /dev/null +++ b/autobahn/Dockerfile @@ -0,0 +1,14 @@ +# Builds the gowest Autobahn echo server. Context is the repository root (see +# docker-compose.yml), so the whole module is available to `go build`. +FROM golang:1.22-alpine AS build +WORKDIR /src +COPY go.mod ./ +# No external dependencies, so there is nothing to download; copy the source and +# build the static echo server. +COPY . . +RUN CGO_ENABLED=0 go build -o /bin/autobahn-server ./autobahn + +FROM alpine:3.20 +COPY --from=build /bin/autobahn-server /bin/autobahn-server +EXPOSE 9001 +ENTRYPOINT ["/bin/autobahn-server", "-addr", ":9001"] diff --git a/autobahn/README.md b/autobahn/README.md new file mode 100644 index 0000000..114ad50 --- /dev/null +++ b/autobahn/README.md @@ -0,0 +1,76 @@ +# Autobahn conformance testing + +[Autobahn|Testsuite](https://github.com/crossbario/autobahn-testsuite) is the +reference test suite for WebSocket implementations. It exercises every corner of +RFC 6455 — framing, fragmentation, UTF-8 handling, close codes, ping/pong and +limits. + +gowest is a **server-only** library, so the suite runs in **fuzzing-client** +mode: the Autobahn client connects to the gowest echo server in +[`server.go`](server.go) and drives every test case; the server echoes each +message back. Protocol violations are detected and reported by gowest itself +(it answers control frames, validates UTF-8 and rejects malformed frames), which +is exactly what the conformance cases check. + +## Run it with Docker (recommended) + +From this directory: + +```sh +docker compose up --build --abort-on-container-exit +``` + +This builds the gowest echo server, starts the upstream Autobahn image, runs the +full case set and writes an HTML report to `report/`. When it finishes: + +```sh +open report/index.html # macOS +# or: xdg-open report/index.html +``` + +Tear down with `docker compose down`. + +## Run it manually (without Docker) + +Start the echo server (from the repository root): + +```sh +go run ./autobahn # listens on :9001 +``` + +Then run Autobahn against it however you have it installed — for example with +the upstream image, pointing it at the host server: + +```sh +docker run -it --rm \ + -v "$PWD/autobahn/fuzzingclient.json:/config/fuzzingclient.json:ro" \ + -v "$PWD/autobahn/report:/config/report" \ + --network host \ + crossbario/autobahn-testsuite:25.10.1 \ + wstest -m fuzzingclient -s /config/fuzzingclient.json +``` + +(Adjust the `url` in `fuzzingclient.json` to `ws://127.0.0.1:9001` when not +using the compose network.) + +## Interpreting the report + +`report/index.html` lists every case with a status: + +- **Pass** / **Non-Strict** — conformant. Non-Strict means the implementation + handled the case acceptably but not in the single strictest way (commonly the + exact timing of a close); it is not a failure. +- **Fail** — a genuine conformance bug worth filing. + +Performance cases (section 9, large-message and many-frame throughput) are timing +benchmarks rather than pass/fail correctness checks. + +## Files + +| File | Purpose | +| ---------------------- | ---------------------------------------------------- | +| `server.go` | gowest echo server under test | +| `fuzzingclient.json` | Autobahn case selection and target server | +| `Dockerfile` | Builds the echo server image | +| `docker-compose.yml` | Wires the server and the Autobahn client together | +| `report/` | Generated HTML report (git-ignored) | diff --git a/autobahn/check_report.py b/autobahn/check_report.py new file mode 100644 index 0000000..f762ec2 --- /dev/null +++ b/autobahn/check_report.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Gate CI on the Autobahn|Testsuite conformance report. + +`wstest` exits 0 regardless of whether cases passed, so CI cannot rely on its +exit code. This script reads the generated report/index.json and exits non-zero +if any case is non-conformant, printing a per-case summary either way. + +A case is conformant when both its "behavior" and "behaviorClose" verdicts are +one of OK / NON-STRICT / INFORMATIONAL / UNIMPLEMENTED. NON-STRICT means the +implementation handled the case acceptably though not in the single strictest +way (commonly close timing); it is not a failure. Anything else (FAILED, WRONG +CODE, UNCLEAN, ...) is a genuine conformance bug and fails the build. +""" + +import json +import os +import sys + +ACCEPTABLE = {"OK", "NON-STRICT", "INFORMATIONAL", "UNIMPLEMENTED"} + +REPORT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "report", "index.json") + + +def main() -> int: + if not os.path.exists(REPORT): + print(f"FAIL: {REPORT} not found — did the Autobahn client run and " + f"reach the server?", file=sys.stderr) + return 1 + + with open(REPORT) as f: + index = json.load(f) + + total = 0 + failures = [] + for agent, cases in index.items(): + for case, result in cases.items(): + total += 1 + behavior = result.get("behavior", "MISSING") + behavior_close = result.get("behaviorClose", "MISSING") + if behavior not in ACCEPTABLE or behavior_close not in ACCEPTABLE: + failures.append((agent, case, behavior, behavior_close)) + + print(f"Autobahn: {total} cases run, {len(failures)} non-conformant") + for agent, case, behavior, behavior_close in failures: + print(f" FAIL [{agent}] case {case}: " + f"behavior={behavior} behaviorClose={behavior_close}") + + if total == 0: + print("FAIL: report contained no cases", file=sys.stderr) + return 1 + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/autobahn/docker-compose.yml b/autobahn/docker-compose.yml new file mode 100644 index 0000000..857e65a --- /dev/null +++ b/autobahn/docker-compose.yml @@ -0,0 +1,27 @@ +# Runs gowest against the Autobahn|Testsuite fuzzing client. +# +# `server` is the gowest echo server; `fuzzingclient` is the upstream Autobahn +# image, which connects to it, runs every conformance case and writes an HTML +# report to ./report. Run from this directory: +# +# docker compose up --build --abort-on-container-exit +# +# then open report/index.html. See README.md in this directory for details. +services: + server: + build: + # Build context is the repo root so the whole gowest module is available. + context: .. + dockerfile: autobahn/Dockerfile + expose: + - "9001" + + fuzzingclient: + image: crossbario/autobahn-testsuite:25.10.1 + depends_on: + - server + volumes: + - ./fuzzingclient.json:/config/fuzzingclient.json:ro + - ./report:/config/report + command: wstest -m fuzzingclient -s /config/fuzzingclient.json + working_dir: /config diff --git a/autobahn/fuzzingclient.json b/autobahn/fuzzingclient.json new file mode 100644 index 0000000..86c6c5c --- /dev/null +++ b/autobahn/fuzzingclient.json @@ -0,0 +1,12 @@ +{ + "outdir": "./report", + "servers": [ + { + "agent": "gowest", + "url": "ws://server:9001" + } + ], + "cases": ["*"], + "exclude-cases": [], + "exclude-agent-cases": {} +} diff --git a/autobahn/server.go b/autobahn/server.go new file mode 100644 index 0000000..1ff2bf3 --- /dev/null +++ b/autobahn/server.go @@ -0,0 +1,72 @@ +// Command autobahn-server is a WebSocket echo server used to validate gowest's +// RFC 6455 conformance against the Autobahn|Testsuite fuzzing client. +// +// gowest is a server-only library, so the test runs in Autobahn's +// "fuzzingclient" mode: the Autobahn client connects to this server and drives +// every protocol test case, and the server simply echoes each message back with +// the same opcode. gowest answers ping/pong and close frames, validates UTF-8 in +// text messages and rejects malformed frames automatically, which is exactly +// what the conformance cases probe. +// +// It listens on :9001 by default (override with -addr) and lives in the root +// module, so it depends only on gowest and the standard library. +package main + +import ( + "context" + "errors" + "flag" + "log" + "net/http" + + "github.com/ystepanoff/gowest" +) + +func main() { + addr := flag.String("addr", ":9001", "listen address for the Autobahn fuzzing client") + flag.Parse() + + http.HandleFunc("/", echo) + log.Printf("autobahn echo server listening on %s", *addr) + log.Fatal(http.ListenAndServe(*addr, nil)) +} + +func echo(w http.ResponseWriter, r *http.Request) { + c, err := gowest.Accept(r.Context(), w, r, &gowest.AcceptOptions{ + // Autobahn connects without an Origin header, but allow any so the + // harness can be pointed at this server from anywhere. + OriginPatterns: []string{"*"}, + // Several conformance cases send multi-megabyte messages; raise the cap + // well above the largest so they are echoed rather than rejected. + MaxMessageBytes: 64 << 20, + }) + if err != nil { + log.Printf("accept: %v", err) + return + } + defer c.Close(gowest.StatusNormalClosure, "") + + ctx := context.Background() + for { + typ, data, err := c.Read(ctx) + if err != nil { + // Most Autobahn cases end by either closing cleanly (*CloseError) or + // sending a deliberately malformed frame that gowest correctly + // rejects (*ProtocolError). Both are expected outcomes for a + // conformance run, so neither is logged; only a genuinely unexpected + // error (e.g. I/O) is surfaced, so a clean run stays quiet and real + // anomalies stand out. + var ( + ce *gowest.CloseError + pe *gowest.ProtocolError + ) + if !errors.As(err, &ce) && !errors.As(err, &pe) { + log.Printf("read: %v", err) + } + return + } + if err := c.Write(ctx, typ, data); err != nil { + return + } + } +} diff --git a/conn_test.go b/conn_test.go index 3a524e4..1409ccb 100644 --- a/conn_test.go +++ b/conn_test.go @@ -126,6 +126,40 @@ func (c *testClient) readFrame() (opcode byte, payload []byte, err error) { return opcode, payload, err } +// TestAcceptUpgradeHeaderCaseInsensitive guards RFC 6455 §4.2.1: the "Upgrade" +// token must be matched case-insensitively. Conformant clients (and the Autobahn +// suite) send "Upgrade: WebSocket"; a case-sensitive check rejects every such +// handshake. The hand-rolled testClient sends lowercase, so this is exercised +// directly against Accept with each casing. +func TestAcceptUpgradeHeaderCaseInsensitive(t *testing.T) { + for _, upgrade := range []string{"websocket", "WebSocket", "WEBSOCKET"} { + t.Run(upgrade, func(t *testing.T) { + c1, c2 := net.Pipe() + t.Cleanup(func() { c1.Close(); c2.Close() }) + go io.Copy(io.Discard, c2) // drain the 101 response and close frame + + reqText := "GET / HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Upgrade: " + upgrade + "\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + + "Sec-WebSocket-Version: 13\r\n\r\n" + req, err := http.ReadRequest(bufio.NewReader(strings.NewReader(reqText))) + if err != nil { + t.Fatalf("build request: %v", err) + } + rec := &hijackRecorder{conn: c1, rw: bufio.NewReadWriter( + bufio.NewReader(c1), bufio.NewWriter(c1))} + + conn, err := Accept(context.Background(), rec, req, &AcceptOptions{OriginPatterns: []string{"*"}}) + if err != nil { + t.Fatalf("Accept rejected Upgrade: %q: %v", upgrade, err) + } + conn.Close(StatusNormalClosure, "") + }) + } +} + func TestAcceptHandshake(t *testing.T) { _, resp := dial(t, func(w http.ResponseWriter, r *http.Request) { c, err := Accept(r.Context(), w, r, &AcceptOptions{OriginPatterns: []string{"*"}}) diff --git a/frame_test.go b/frame_test.go index f4e1dbb..8300247 100644 --- a/frame_test.go +++ b/frame_test.go @@ -312,6 +312,12 @@ func TestParseClosePayload(t *testing.T) { wantErr: true, wantErrCD: StatusProtocolError, }, + { + name: "reserved code 1004 rejected", + payload: []byte{0x03, 0xEC}, // 1004 (reserved/undefined in RFC 6455) + wantErr: true, + wantErrCD: StatusProtocolError, + }, { name: "reserved code 1005 rejected", payload: []byte{0x03, 0xED}, // 1005 diff --git a/status.go b/status.go index 82e6964..e658e88 100644 --- a/status.go +++ b/status.go @@ -67,13 +67,15 @@ func (e *CloseError) Error() string { } // validProtocolCode reports whether code is a status code a peer is permitted -// to send in a close frame, per RFC 6455 section 7.4.1. The reserved codes -// 1005, 1006 and 1015 must never appear on the wire; codes in the registered -// range 1000-1014 (excluding those) and the application range 3000-4999 are -// accepted. The unregistered 1016-2999 range is rejected. +// to send in a close frame, per RFC 6455 section 7.4.1. The reserved codes 1004, +// 1005, 1006 and 1015 must never appear on the wire; the remaining registered +// range 1000-1014 and the application range 3000-4999 are accepted. The +// unregistered 1016-2999 range is rejected. func validProtocolCode(code StatusCode) bool { switch code { - case StatusNoStatusReceived, StatusAbnormalClosure, StatusTLSHandshake: + // 1004 has no assigned meaning and, like 1005/1006/1015, is reserved and + // must not be sent (RFC 6455 section 7.4.1). It has no named constant. + case 1004, StatusNoStatusReceived, StatusAbnormalClosure, StatusTLSHandshake: return false } switch {