Keep neoirc-cli sessions on localhost and test the relay goroutine stop
check / check (push) Waiting to run
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:
+1
-2
@@ -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; }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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!"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user