Keep neoirc-cli sessions on localhost and test the relay goroutine stop
check / check (push) Waiting to run

The auth cookie is always Secure, and Go 1.24's cookie jar sends a
Secure cookie only over HTTPS, so neoirc-cli lost its session against a
plain-HTTP server on localhost. Its jar now treats a server on localhost
as HTTPS, as curl does. The README's Transport Security section and
Python example say what a client needs.

A test checks that closing a registered IRC client's connection stops
its relay goroutine. The README Entrypoints section names a make target
only for the scripts that have one, and the Dockerfile says why the
test phase runs with -p 4.

Model: opus-5-5
This commit is contained in:
2026-10-06 14:13:31 +00:00
parent 60c0d4b5e3
commit 58d8f5cc94
5 changed files with 193 additions and 12 deletions
+1 -2
View File
@@ -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; }
+19 -9
View File
@@ -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",
+25 -1
View File
@@ -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
+97
View File
@@ -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)
}
})
}
}
+51
View File
@@ -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!"