v0.24.0 cli long and short options, http sink viewer and new logo
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-04 18:28:47 -04:00
1 parent bf410e1225
commit 9e86d7999d
42 files changed
+786 -404

No files matched your search

+45 -34
View File
@@ -10,6 +10,7 @@ import (
"maps"
"net"
"net/http"
"path"
"strconv"
"strings"
"sync"
@@ -77,7 +78,7 @@ type HTTPSink struct {
// Authorization
auth *authz.Policy
web map[string]http.Handler // proxy mode: GET /auth/... browser files
web map[string]http.Handler // GET paths of the browser files and the root
// Runtime. Stop closes flush; the broker then moves what remains of its
// input to tail and closes flushed; each stream writes its queue, tail
@@ -128,22 +129,21 @@ func NewHTTPSinkPlugin(
}
if opts.StreamPath == "" {
opts.StreamPath = DefaultHTTPStreamPath
} else if !strings.HasPrefix(opts.StreamPath, "/") {
return nil, fmt.Errorf("stream_path: must start with '/'")
}
if opts.StatusPath == "" {
opts.StatusPath = DefaultHTTPStatusPath
} else if !strings.HasPrefix(opts.StatusPath, "/") {
return nil, fmt.Errorf("status_path: must start with '/'")
}
for _, o := range []struct{ name, p string }{{"stream_path", opts.StreamPath}, {"status_path", opts.StatusPath}} {
if !routable(o.p) {
return nil, fmt.Errorf("%s %q: must start with '/', hold no '//', '.' or '..' segment, and none of '{', '}', '%%', '?', '#'", o.name, o.p)
}
if p := o.p; p == chain.AuthPath || strings.HasPrefix(p, chain.AuthPath+"/") {
return nil, fmt.Errorf("%s and the paths under it are reserved for authentication", chain.AuthPath)
}
}
if opts.StreamPath == opts.StatusPath {
return nil, fmt.Errorf("stream_path and status_path must differ")
}
for _, p := range []string{opts.StreamPath, opts.StatusPath} {
if p == chain.AuthPath || strings.HasPrefix(p, chain.AuthPath+"/") {
return nil, fmt.Errorf("%s and the paths under it are reserved for authentication", chain.AuthPath)
}
}
if opts.BufferSize <= 0 {
opts.BufferSize = DefaultHTTPBufferSize
}
@@ -160,15 +160,13 @@ func NewHTTPSinkPlugin(
}
switch {
case (opts.LoginPage || opts.ViewerPage) && !authPolicy.BehindProxy():
return nil, errors.New("login_page and viewer_page need auth.trusted_proxies: browsers log in only behind a TLS-terminating proxy")
return nil, errors.New("login_page and viewer_page apply to scram behind auth.trusted_proxies, where browsers log in; without auth or under mtls the viewer is always served")
case opts.ViewerPage && !opts.LoginPage:
return nil, errors.New("viewer_page needs login_page, where it sends a signed-out viewer")
}
var web map[string]http.Handler
if authPolicy.BehindProxy() {
if web, err = webHandlers(opts); err != nil {
return nil, err
}
web, err := webHandlers(opts, authPolicy)
if err != nil {
return nil, err
}
h := &HTTPSink{
@@ -202,7 +200,7 @@ func NewHTTPSinkPlugin(
"mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert,
"auth", authPolicy.Describe(),
"login_page", opts.LoginPage,
"viewer_page", opts.ViewerPage)
"viewer_page", web[chain.AuthPath+"/view"] != nil)
tlsx.LogWarnings(logger, "http_sink", id, opts.TLS, true)
authPolicy.LogStartup(logger, "http_sink", id, false)
return h, nil
@@ -248,28 +246,26 @@ func (h *HTTPSink) serve(ctx context.Context, ln net.Listener) error {
}
mux := http.NewServeMux()
// Method-scoped patterns: mux answers 405 with Allow header on non-GET
mux.HandleFunc(http.MethodGet+" "+h.config.StreamPath, h.handleStream)
mux.HandleFunc(http.MethodGet+" "+h.config.StatusPath, h.handleStatus)
mux.HandleFunc(http.MethodGet+" "+exact(h.config.StreamPath), h.handleStream)
mux.HandleFunc(http.MethodGet+" "+exact(h.config.StatusPath), h.handleStatus)
// A GET pattern also serves HEAD, and a HEAD stream is a registered client
// whose body writes are discarded: it never reads, so nothing but the peer
// closing the connection ends it. The status path answers one either way.
mux.HandleFunc(http.MethodHead+" "+h.config.StreamPath, streamHeadNotAllowed)
mux.HandleFunc(http.MethodHead+" "+exact(h.config.StreamPath), streamHeadNotAllowed)
// One wrapper covers stream and status, and keeps the handlers themselves
// unaware of authorization. Login and its browser files sit outside it;
// in proxy mode everything sits behind the proxy gate.
var handler http.Handler = mux
if h.auth.Enabled() {
outer := http.NewServeMux()
outer.HandleFunc(http.MethodPost+" "+chain.AuthPath, h.handleAuth)
for p, file := range h.web {
outer.Handle(http.MethodGet+" "+p, file)
}
outer.Handle("/", h.authMiddleware(mux))
handler = outer
if h.auth.BehindProxy() {
handler = h.proxyGate(outer)
}
// unaware of authorization; a nil policy admits every request. Login, the
// browser files and the root sit outside it; in proxy mode everything sits
// behind the proxy gate.
outer := http.NewServeMux()
outer.HandleFunc(http.MethodPost+" "+chain.AuthPath, h.handleAuth)
for p, file := range h.web {
outer.Handle(http.MethodGet+" "+p, file)
}
outer.Handle("/", h.authMiddleware(mux))
var handler http.Handler = outer
if h.auth.BehindProxy() {
handler = h.proxyGate(outer)
}
h.server = &http.Server{
@@ -671,6 +667,21 @@ func (h *HTTPSink) handleAuth(w http.ResponseWriter, r *http.Request) {
}
}
// routable reports a path ServeMux and a URL take literally: clean, a trailing
// '/' aside, without the braces of wildcards, an escape, a query or a fragment
func routable(p string) bool {
c := path.Clean(p)
return strings.HasPrefix(p, "/") && !strings.ContainsAny(p, "{}%?#") && (c == p || c != "/" && c+"/" == p)
}
// exact keeps a path ending in '/' from matching every path beneath it
func exact(p string) string {
if strings.HasSuffix(p, "/") {
return p + "{$}"
}
return p
}
// streamHeadNotAllowed refuses a body-less read of a stream that is only a body
func streamHeadNotAllowed(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Allow", http.MethodGet)
+92 -29
View File
@@ -4,14 +4,12 @@ import (
"bufio"
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"maps"
"net/http"
"net/http/httptest"
"os"
"slices"
"strings"
"testing"
@@ -138,17 +136,6 @@ func TestHeadOnStreamPathIsRefused(t *testing.T) {
// open stream keeps the wildcard so browser dashboards still work.
func TestWildcardCORSOnlyWithoutAuth(t *testing.T) {
pki := testutil.NewPKI(t, "viewer-01")
caPEM, err := os.ReadFile(pki.CA)
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
roots.AppendCertsFromPEM(caPEM)
clientCert, err := tls.LoadX509KeyPair(pki.ClientCert, pki.ClientKey)
if err != nil {
t.Fatal(err)
}
open, _ := newTestHTTPSink(t, nil)
gated, _ := newTestHTTPSink(t, map[string]any{
"tls": map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey,
@@ -162,8 +149,7 @@ func TestWildcardCORSOnlyWithoutAuth(t *testing.T) {
}{{"open", open, "*"}, {"gated", gated, ""}} {
client, baseURL := serveTestHTTPSink(t, tc.sink)
if tc.sink.tlsConfig != nil {
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{RootCAs: roots, Certificates: []tls.Certificate{clientCert}}}
baseURL = "https" + strings.TrimPrefix(baseURL, "http")
baseURL = trustPKI(t, client, baseURL, pki, true)
}
resp, err := client.Get(baseURL + "/stream")
if err != nil {
@@ -189,14 +175,7 @@ func TestLoginEndpointBypassesTheGate(t *testing.T) {
"auth": map[string]any{"type": "scram", "credentials_file": creds},
})
client, baseURL := serveTestHTTPSink(t, gated)
caPEM, err := os.ReadFile(pki.CA)
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
roots.AppendCertsFromPEM(caPEM)
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{RootCAs: roots}}
baseURL = "https" + strings.TrimPrefix(baseURL, "http")
baseURL = trustPKI(t, client, baseURL, pki, false)
hello := `{"logwisp":1,"scram":{"username":"viewer-01","client_nonce":"abcdefgh"}}`
resp, err := client.Post(baseURL+"/auth", "application/json", strings.NewReader(hello))
@@ -268,6 +247,85 @@ func TestProxyModeServesBrowserFiles(t *testing.T) {
}
}
// The root leads to the page a browser can use: the viewer, needing no login
// without auth and under mtls; in proxy mode the viewer, or else the login
// page; nothing under direct scram, which a browser cannot log in to; and an
// endpoint at "/" keeps the root.
func TestRootLeadsToThePageABrowserCanUse(t *testing.T) {
pki := testutil.NewPKI(t, "viewer-01")
tlsOn := map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey}
mtlsOn := map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey,
"client_auth": true, "client_ca_file": pki.CA}
creds := scramCredentials(t)
proxy := map[string]any{"type": "scram", "credentials_file": creds, "trusted_proxies": []any{"127.0.0.1"}}
for _, tc := range []struct {
name string
opts map[string]any
code int
target, mode string // the root's Location, and that page's logwisp-login
}{
{"no auth", nil, http.StatusSeeOther, "auth/view", "none"},
{"mtls", map[string]any{"tls": mtlsOn, "auth": map[string]any{"type": "mtls"}}, http.StatusSeeOther, "auth/view", "none"},
{"proxy, viewer", map[string]any{"auth": proxy, "login_page": true, "viewer_page": true}, http.StatusSeeOther, "auth/view", "scram"},
{"proxy, login page", map[string]any{"auth": proxy, "login_page": true}, http.StatusSeeOther, "auth/login", ""},
{"direct scram", map[string]any{"tls": tlsOn, "auth": map[string]any{"type": "scram", "credentials_file": creds}}, http.StatusUnauthorized, "", ""},
{"status at the root", map[string]any{"status_path": "/"}, http.StatusOK, "", ""},
} {
h, _ := newTestHTTPSink(t, tc.opts)
client, baseURL := serveTestHTTPSink(t, h)
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
if h.tlsConfig != nil {
baseURL = trustPKI(t, client, baseURL, pki, h.tlsConfig.ClientAuth == tls.RequireAndVerifyClientCert)
}
get := func(path string) (*http.Response, string) {
req, _ := http.NewRequest(http.MethodGet, baseURL+path, nil)
resp, err := client.Do(proxied(req))
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return resp, string(body)
}
resp, _ := get("/")
if resp.StatusCode != tc.code || resp.Header.Get("Location") != tc.target {
t.Errorf("%s: GET / = %d to %q, want %d to %q", tc.name, resp.StatusCode, resp.Header.Get("Location"), tc.code, tc.target)
}
for _, file := range []string{tc.target + ".js", "auth/scram.js", "auth/style.css"} {
if resp, _ := get("/" + file); tc.target != "" && resp.StatusCode != http.StatusOK {
t.Errorf("%s: /%s = %d, which its page loads", tc.name, file, resp.StatusCode)
}
}
if tc.mode == "" {
continue
}
resp, page := get("/" + tc.target)
if want := `name="logwisp-login" content="` + tc.mode + `"`; resp.StatusCode != http.StatusOK || !strings.Contains(page, want) {
t.Errorf("%s: /%s = %d, without %s", tc.name, tc.target, resp.StatusCode, want)
}
}
}
// A path ending in "/" matches itself only: a stream at the root does not
// answer every other path, a browser's favicon request included
func TestEndpointPathsMatchExactly(t *testing.T) {
h, manager := newTestHTTPSink(t, map[string]any{"stream_path": "/"})
client, baseURL := serveTestHTTPSink(t, h)
for path, want := range map[string]int{"/favicon.ico": http.StatusNotFound, "/auth/view": http.StatusOK} {
resp, err := client.Get(baseURL + path)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != want {
t.Errorf("GET %s = %d, want %d", path, resp.StatusCode, want)
}
}
if n := manager.GetSessionCount(); n != 0 {
t.Errorf("sessions = %d, want 0", n)
}
}
// Behind a proxy nothing answers a peer outside trusted_proxies, not even the
// login page or the challenge.
func TestProxyModeRefusesDirectPeers(t *testing.T) {
@@ -289,9 +347,10 @@ func TestProxyModeRefusesDirectPeers(t *testing.T) {
}
}
// Pages exist only behind a proxy, where browsers can log in; the viewer
// needs the login page; /auth and the paths under it are reserved.
func TestPagesNeedProxyMode(t *testing.T) {
// The constructor refuses what it cannot serve: the login pages outside
// scram's proxy mode, a viewer without the login page, a path under /auth, and
// a path ServeMux or a URL would not take literally, which panicked at Start.
func TestUnservableOptionsAreRefused(t *testing.T) {
scram := map[string]any{"type": "scram", "credentials_file": scramCredentials(t)}
pki := testutil.NewPKI(t, "viewer-01")
tlsOn := map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey}
@@ -300,6 +359,10 @@ func TestPagesNeedProxyMode(t *testing.T) {
"login page without proxies": {"tls": tlsOn, "auth": scram, "login_page": true},
"viewer without login": {"auth": proxy, "viewer_page": true},
"stream under /auth": {"auth": proxy, "stream_path": "/auth/stream"},
"wildcard brace in a path": {"stream_path": "/a{b"},
"unclean path": {"status_path": "//status"},
"escape aliasing a path": {"stream_path": "/x", "status_path": "/%78"},
"query in a path": {"status_path": "/a?b"},
} {
maps.Copy(opts, map[string]any{"host": "127.0.0.1", "port": int64(8081)})
if _, err := NewHTTPSinkPlugin("stream", opts, log.NewLogger(), nil); err == nil {
@@ -326,7 +389,7 @@ func TestSSEFramesEveryLineAsData(t *testing.T) {
// stopped reading does; Stop returns at the flush bound.
func TestStopWritesQueuedEventsToReadingStreams(t *testing.T) {
const n = 2000 // of 8 KiB: more than a silent peer's socket buffers hold
httpSink, _ := newTestHTTPSink(t, map[string]any{"buffer_size": int64(n), "client_buffer_size": int64(64), "write_timeout_ms": int64(500)})
httpSink, _ := newTestHTTPSink(t, map[string]any{"buffer_size": int64(n), "client_buffer_size": int64(64), "write_timeout_ms": int64(2000)})
client, baseURL := serveTestHTTPSink(t, httpSink)
var scanners []*bufio.Scanner
for range 2 {
@@ -383,8 +446,8 @@ func TestStopWritesQueuedEventsToReadingStreams(t *testing.T) {
}()
time.Sleep(100 * time.Millisecond)
httpSink.clientsMu.Unlock()
if d := <-stopped; d > 1500*time.Millisecond {
t.Errorf("Stop took %v past a 500 ms flush bound", d)
if d := <-stopped; d > 3500*time.Millisecond {
t.Errorf("Stop took %v past a 2 s flush bound", d)
}
if r := <-done; r.lines != n || !r.disconnect {
t.Fatalf("reading stream received %d of %d lines, disconnect %v", r.lines, n, r.disconnect)
+25
View File
@@ -2,10 +2,14 @@ package http
import (
"context"
"crypto/tls"
"crypto/x509"
"maps"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -51,6 +55,27 @@ func serveTestHTTPSink(t *testing.T, h *HTTPSink) (*http.Client, string) {
return client, "http://" + listener.Addr().String()
}
// trustPKI points client at https, trusting pki's CA and, with cert,
// presenting its client certificate
func trustPKI(t *testing.T, client *http.Client, baseURL string, pki *testutil.PKI, cert bool) string {
t.Helper()
caPEM, err := os.ReadFile(pki.CA)
if err != nil {
t.Fatal(err)
}
cfg := &tls.Config{RootCAs: x509.NewCertPool()}
cfg.RootCAs.AppendCertsFromPEM(caPEM)
if cert {
pair, err := tls.LoadX509KeyPair(pki.ClientCert, pki.ClientKey)
if err != nil {
t.Fatal(err)
}
cfg.Certificates = []tls.Certificate{pair}
}
client.Transport = &http.Transport{TLSClientConfig: cfg}
return "https" + strings.TrimPrefix(baseURL, "http")
}
// scramCredentials writes a credentials file holding viewer-01 under a cheap
// Argon2 profile
func scramCredentials(t *testing.T) string {
+38 -18
View File
@@ -28,29 +28,30 @@ var webTypes = map[string]string{
".html": "text/html; charset=utf-8",
}
func loginOn(o *config.HTTPSinkOptions) bool { return o.LoginPage }
func viewerOn(o *config.HTTPSinkOptions) bool { return o.ViewerPage }
// webRoutes are served under /auth/ in proxy mode: the client library always,
// so a site can load it, and the pages when enabled
// webRoutes are the browser files under /auth/, each with the page that needs
// it; "" marks a shared file, served with any page and always in proxy mode,
// where a site may load the client library on its own
var webRoutes = []struct {
name, file string
on func(*config.HTTPSinkOptions) bool
name, file, page string
}{
{"scram.js", "scram.js", func(*config.HTTPSinkOptions) bool { return true }},
{"style.css", "style.css", loginOn},
{"login", "login.html", loginOn},
{"login.js", "login.js", loginOn},
{"view", "view.html", viewerOn},
{"view.js", "view.js", viewerOn},
{"scram.js", "scram.js", ""},
{"style.css", "style.css", ""},
{"login", "login.html", "login"},
{"login.js", "login.js", "login"},
{"view", "view.html", "view"},
{"view.js", "view.js", "view"},
}
// webHandlers maps each enabled GET path to its file. The viewer learns a
// custom status path from a meta tag, as the CSP allows no inline script.
func webHandlers(o *config.HTTPSinkOptions) (map[string]http.Handler, error) {
// webHandlers maps each GET path a browser uses to its handler. Without a
// login (no auth, or mtls: the certificate is the login) the viewer is always
// served; under scram the pages need proxy mode. The viewer learns the status
// path and whether it logs in from meta tags, as the CSP allows no inline script.
func webHandlers(o *config.HTTPSinkOptions, p *authz.Policy) (map[string]http.Handler, error) {
open := !p.NeedsLogin()
on := map[string]bool{"": open || p.BehindProxy(), "login": o.LoginPage, "view": open || o.ViewerPage}
handlers := make(map[string]http.Handler)
for _, r := range webRoutes {
if !r.on(o) {
if !on[r.page] {
continue
}
data, err := webFiles.ReadFile("web/" + r.file)
@@ -59,13 +60,32 @@ func webHandlers(o *config.HTTPSinkOptions) (map[string]http.Handler, error) {
}
if r.file == "view.html" {
status := html.EscapeString(strings.TrimPrefix(o.StatusPath, "/"))
data = bytes.Replace(data, []byte(`content="status"`), []byte(`content="`+status+`"`), 1)
data = bytes.Replace(data, []byte(`name="logwisp-status" content="status"`), []byte(`name="logwisp-status" content="`+status+`"`), 1)
if open {
data = bytes.Replace(data, []byte(`name="logwisp-login" content="scram"`), []byte(`name="logwisp-login" content="none"`), 1)
}
}
handlers[chain.AuthPath+"/"+r.name] = serveWebFile(data, webTypes[path.Ext(r.file)])
}
switch {
case o.StreamPath == "/" || o.StatusPath == "/": // the endpoint owns the root
case on["view"]:
handlers["/{$}"] = seeOther("auth/view")
case on["login"]:
handlers["/{$}"] = seeOther("auth/login")
}
return handlers, nil
}
// seeOther redirects to a relative target, which a proxy prefix keeps intact;
// http.Redirect would resolve it against the backend's path
func seeOther(target string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Location", target)
w.WriteHeader(http.StatusSeeOther)
})
}
func serveWebFile(data []byte, contentType string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hd := w.Header()
+1
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LogWisp stream</title>
<meta name="logwisp-status" content="status">
<meta name="logwisp-login" content="scram">
<link rel="stylesheet" href="style.css">
<script type="module" src="view.js"></script>
</head>
+11 -5
View File
@@ -1,8 +1,11 @@
import { cookiesUsable, login, loginUnavailable, logout, stream } from "./scram.js";
const base = new URL("..", location.href);
// The server writes its status path here, relative to the mount
const statusPath = document.querySelector('meta[name="logwisp-status"]').content;
// The server writes its status path here, relative to the mount, and "none"
// as the login when the browser needs none: no auth, or its mTLS certificate
const meta = (name) => document.querySelector(`meta[name="logwisp-${name}"]`).content;
const statusPath = meta("status");
const open = meta("login") === "none";
const MAX_LINES = 5000;
const log = document.getElementById("log");
const state = document.getElementById("state");
@@ -11,10 +14,11 @@ const form = document.getElementById("login");
const progress = document.getElementById("progress");
const status = document.getElementById("status");
const unavailable = loginUnavailable();
// A page without a login needs no WebCrypto, so plain http works too
const unavailable = open ? "" : loginUnavailable();
// Without cookies the session is a token in this variable only: never stored,
// so it ends with the page, or earlier with its lifetime or sign-out
let tokenMode = !unavailable && !cookiesUsable();
let tokenMode = !open && !unavailable && !cookiesUsable();
let token = null;
// The login page comes back with this fragment: a 401 now means the browser
// dropped the session cookie
@@ -45,7 +49,8 @@ async function connect() {
} catch {
return retry("server unreachable");
}
if (res.status === 401) return sessionEnded();
// An open page has no session to end: a 401 there is a proxy's, so it retries
if (res.status === 401 && !open) return sessionEnded();
fresh = false;
const path = res.ok ? (await res.json().catch(() => ({})))?.endpoints?.stream : null;
if (typeof path !== "string" || !path.startsWith("/")) {
@@ -168,5 +173,6 @@ if (unavailable) {
state.textContent = unavailable;
signOut.hidden = true;
} else {
signOut.hidden = open;
connect();
}
+3 -3
View File
@@ -80,7 +80,7 @@ func TestStopWritesQueuedEventsToReadingClients(t *testing.T) {
t.Cleanup(manager.Stop)
created, err := NewTCPSinkPlugin("out", map[string]any{
"host": "127.0.0.1", "port": p, "buffer_size": int64(n), "client_buffer_size": int64(64),
"write_timeout_ms": int64(500),
"write_timeout_ms": int64(2000),
}, log.NewLogger(), session.NewProxy(manager, "out"))
if err != nil {
t.Fatal(err)
@@ -132,8 +132,8 @@ func TestStopWritesQueuedEventsToReadingClients(t *testing.T) {
}()
time.Sleep(100 * time.Millisecond)
sink.clientsMu.Unlock()
if d := <-stopped; d > 1500*time.Millisecond {
t.Errorf("Stop took %v past a 500 ms flush bound", d)
if d := <-stopped; d > 3500*time.Millisecond {
t.Errorf("Stop took %v past a 2 s flush bound", d)
}
if got := <-lines; got != n {
t.Fatalf("reading client received %d of %d lines", got, n)