diff --git a/Dockerfile b/Dockerfile index ace81b5..371400a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,8 +24,6 @@ RUN golangci-lint run --config .golangci.yml ./... # Test phase, built alone by script/test. -race needs cgo and so a C # compiler, which the Debian Go image ships and the alpine one does not. -# -p 4 runs at most four test binaries at once: under -race each one -# costs a few hundred MB, and the default is one per core. # golang:1.24.13-bookworm, 2026-10-06 FROM golang@sha256:1a6d4452c65dea36aac2e2d606b01b4a029ec90cc1ae53890540ce6173ea77ac AS test WORKDIR /src @@ -33,6 +31,7 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js +# -p 4 because test runs on a shared build host cap their parallelism. RUN go test -p 4 -timeout 90s -race -cover ./... || \ { echo "--- Rerunning with -v for details ---"; \ go test -p 4 -timeout 90s -race -v ./...; exit 1; } diff --git a/README.md b/README.md index d2f1723..e1ed939 100644 --- a/README.md +++ b/README.md @@ -164,8 +164,9 @@ for multi-client access. cryptographically random value (64 hex characters) and returns the user ID and nick in the JSON response body. No auth credential appears in the JSON body. - The auth cookie is HttpOnly, SameSite=Strict, and Secure: clients send it only - over HTTPS, which the TLS-terminating reverse proxy provides. Browsers handle - cookies automatically. **CLI clients (curl, custom HTTP clients) must + over HTTPS, which the TLS-terminating reverse proxy provides, or to a server + on `localhost` (see [Transport Security](#transport-security)). Browsers + handle cookies automatically. **CLI clients (curl, custom HTTP clients) must explicitly save and send cookies** — e.g., using curl's `-c`/`-b` flags or an HTTP cookie jar in their language's HTTP library. - Sessions start anonymous — no password required. When the session expires or @@ -2092,9 +2093,14 @@ PGP/DKIM — the mail server sees everything, but signatures prove authenticity. ### Transport Security -- **HTTPS is strongly recommended** for production deployments. The server - itself serves plain HTTP — use a reverse proxy (nginx, Caddy, etc.) for TLS - termination. +- **Clients need HTTPS to keep a session.** The auth cookie is always `Secure`, + and clients send a `Secure` cookie only over HTTPS. The server itself serves + plain HTTP — use a reverse proxy (nginx, Caddy, etc.) for TLS termination. +- **A server on `localhost`**, as in this document's examples, also works over + plain HTTP with curl, `neoirc-cli`, Chrome and Firefox, which treat + `localhost` as secure. Safari does not. Python's `requests` does not either, + so the [Python example](#implementing-long-poll-in-code) sends the cookie + itself. - **CORS**: The server allows all origins with credentials (`Access-Control-Allow-Credentials: true`), reflecting the request Origin. This enables cookie-based auth from cross-origin clients. Restrict origins in @@ -2428,7 +2434,8 @@ docker run -d \ This repository adheres to the [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) standard. Each script below has a `make` target of the same name that calls it, -except `script/install-precommit`, which is `make hooks`. +except `script/install-precommit`, which is `make hooks`, and `script/cibuild`, +`script/precommit` and `script/projectname`, which have none. - `script/bootstrap`: installs what development needs: make, git, Node and yarn (for prettier), and Go. @@ -2625,16 +2632,19 @@ curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ The key to real-time messaging is the poll loop. Here's the pattern: ```python -# Python example — using requests.Session for automatic cookie handling +# Python example — using requests.Session for cookie handling import requests, json, time BASE = "http://localhost:8080/api/v1" -session = requests.Session() # Manages cookies automatically +session = requests.Session() last_id = 0 -# Create session (cookie set automatically via Set-Cookie header) +# Create session (the server sets the auth cookie via Set-Cookie) resp = session.post(f"{BASE}/session", json={"nick": "pybot"}) print(f"Session: {resp.json()}") +# The auth cookie is Secure, and requests sends it only over HTTPS. +# For a plain-HTTP server on localhost, send it on every request: +session.headers["Cookie"] = f"neoirc_auth={resp.cookies['neoirc_auth']}" # Join channel session.post(f"{BASE}/messages", diff --git a/internal/cli/api/client.go b/internal/cli/api/client.go index 0e21c3e..5dd5972 100644 --- a/internal/cli/api/client.go +++ b/internal/cli/api/client.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/http/cookiejar" "net/url" @@ -41,11 +42,34 @@ func NewClient(baseURL string) *Client { BaseURL: baseURL, HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine Timeout: httpTimeout, - Jar: jar, + Jar: loopbackJar{CookieJar: jar}, }, } } +// loopbackJar also sends the server's auth cookie, which is +// always Secure, over plain HTTP to a server on localhost, as +// curl does. Go 1.24's cookie jar sends a Secure cookie only +// over HTTPS. +type loopbackJar struct { + http.CookieJar +} + +// Cookies returns the cookies to send to target, treating a +// plain-HTTP target on localhost as HTTPS. +func (jar loopbackJar) Cookies(target *url.URL) []*http.Cookie { + host := target.Hostname() + loopback := host == "localhost" || net.ParseIP(host).IsLoopback() + + if target.Scheme == "http" && loopback { + secure := *target + secure.Scheme = "https" + target = &secure + } + + return jar.CookieJar.Cookies(target) +} + // CreateSession creates a new session on the server. // If the server requires hashcash proof-of-work, it // automatically fetches the difficulty and computes a diff --git a/internal/cli/api/client_test.go b/internal/cli/api/client_test.go new file mode 100644 index 0000000..dbd5e0f --- /dev/null +++ b/internal/cli/api/client_test.go @@ -0,0 +1,97 @@ +package neoircapi_test + +import ( + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + api "sneak.berlin/go/neoirc/internal/cli/api" +) + +const cookieValue = "opaque-value" + +// newSessionServer starts a plain-HTTP server that, like +// neoircd, sets a Secure auth cookie when a session is +// created and answers GET /api/v1/state only when that +// cookie comes back. +func newSessionServer(t *testing.T) *httptest.Server { + t.Helper() + + mux := http.NewServeMux() + + mux.HandleFunc("GET /api/v1/server", func( + writer http.ResponseWriter, _ *http.Request, + ) { + _, _ = io.WriteString(writer, `{}`) + }) + + mux.HandleFunc("POST /api/v1/session", func( + writer http.ResponseWriter, _ *http.Request, + ) { + http.SetCookie(writer, &http.Cookie{ + Name: "neoirc_auth", + Value: cookieValue, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + }) + writer.WriteHeader(http.StatusCreated) + + _, _ = io.WriteString(writer, `{"id":1,"nick":"alice"}`) + }) + + mux.HandleFunc("GET /api/v1/state", func( + writer http.ResponseWriter, request *http.Request, + ) { + cookie, err := request.Cookie("neoirc_auth") + if err != nil || cookie.Value != cookieValue { + writer.WriteHeader(http.StatusUnauthorized) + + return + } + + _, _ = io.WriteString( + writer, `{"id":1,"nick":"alice","channels":[]}`, + ) + }) + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + return server +} + +func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) { + t.Parallel() + + server := newSessionServer(t) + + serverURL, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse server URL: %v", err) + } + + for _, host := range []string{"127.0.0.1", "localhost"} { + t.Run(host, func(t *testing.T) { + t.Parallel() + + client := api.NewClient( + "http://" + net.JoinHostPort(host, serverURL.Port()), + ) + + _, err := client.CreateSession("alice") + if err != nil { + t.Fatalf("create session: %v", err) + } + + _, err = client.GetState() + if err != nil { + t.Fatalf("state after creating the session: %v", err) + } + }) + } +} diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index 476e244..0ea6f4e 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -7,6 +7,7 @@ import ( "log/slog" "net" "os" + "runtime" "strings" "testing" "time" @@ -608,6 +609,56 @@ func TestNamesNonExistentChannel(t *testing.T) { ) } +// TestRelayStopsWhenConnectionCloses checks that closing a +// registered client's connection stops the goroutine that +// relays its messages. +// +//nolint:paralleltest // counts every goroutine in the process +func TestRelayStopsWhenConnectionCloses(t *testing.T) { + env := newTestEnv(t) + client := env.dial(t) + + client.register("relaystop") + waitForRelayGoroutines(t, 1) + + err := client.conn.Close() + if err != nil { + t.Fatalf("close: %v", err) + } + + waitForRelayGoroutines(t, 0) +} + +const ( + stackDumpSize = 1 << 20 + relayCheckStep = 10 * time.Millisecond +) + +// waitForRelayGoroutines waits until exactly want goroutines +// are running the relay loop, and fails the test if that does +// not happen within testTimeout. +func waitForRelayGoroutines(t *testing.T, want int) { + t.Helper() + + deadline := time.Now().Add(testTimeout) + stacks := make([]byte, stackDumpSize) + + for { + n := runtime.Stack(stacks, true) + got := strings.Count(string(stacks[:n]), "(*Conn).relayMessages(") + + if got == want { + return + } + + if time.Now().After(deadline) { + t.Fatalf("relay goroutines: got %d, want %d", got, want) + } + + time.Sleep(relayCheckStep) + } +} + func BenchmarkParseMessage(b *testing.B) { line := ":nick!user@host PRIVMSG #channel :Hello, world!"