Files
nebula/localapi/info_test.go
T
Matt RichardsonandClaude Fable 5.1 613c7e3061 Add the identity command and serve it on an opt-in HTTP info API
Build slackhq/nebula#1821 ("who is this vpn address?" over a local
HTTP+JSON socket) on the command registry, so the lookup is one command
on every surface and the socket is one more local API.

identity
- `identity [-address <vpn addr>[:port]]`: no address reports this node;
  with one, this node or the peer behind an established tunnel
- Certificate fields only: name, vpn addresses, networks, unsafe
  networks, groups, fingerprint, issuer, validity, cert version; a peer's
  fingerprint is the one nebula computed when it verified the cert
- Port accepted and stripped, 4in6 unmapped, so a RemoteAddr passes
  straight through
- No tunnel is a commands.NotFound; an empty -address is refused
- Lighthouse TXT lookup shares the owner and peer lookups, keeps answering
  with the initiating certificate, and no longer panics without a default cert
- Served on ssh and nebula ctl too

Info API
- Off until `local_api.info.enabled` with `socket` and optional quoted
  octal `socket_mode` (default "0600"); nothing derived
- Serves commands declaring Command.Identity plus help; others 403 naming
  their surfaces. TestInfoAPISurface pins the set to identity
- GET /v1 is the schema; GET /v1/<cmd>?<flag>=<value> runs one; body is
  the -json rendering via commands.Render; commands.PresentationFormat
  reads json/pretty from the query the way a line is read
- 400 commands.ParamError, 403 commands.WithheldError (what Resolve now
  returns for a withheld name, so help and dispatch type-check it too),
  404 NotFound or unknown, 500 Run error, refusal with no reason, or
  recovered panic with no detail; no redirects; every miss is JSON
- listen takes a mode: bind under umask 0077 then chmod; "0660" plus a
  searchable dir opens it to a group
- The chmod, at bind and on a mode-only reload, goes through chmodSocket,
  which never follows a link: a user who can write the socket's directory
  could otherwise swap the node for a link and have a root daemon put
  0660 or 0666 on any file. Only a socket we own is chmoded. On linux the
  node is held open O_PATH|O_NOFOLLOW, checked by fstat, and chmoded
  through /proc/self/fd, so the check and the chmod land on one inode;
  fchmodat2 is the fallback without /proc. On darwin and the BSDs it is
  an Lstat check and fchmodat(AT_SYMLINK_NOFOLLOW). clearStale Lstats
  too, so a dangling link is refused rather than followed by bind on
  darwin
- One endpoint type owns bind under mutex, bind-before-release move,
  context watch and stop/unlink; each server supplies what it serves
- Reloadable through the same reconcile shape as the control socket:
  mode applied in place, unparsable section keeps what runs, control
  socket path refused by name
- Wire returns Endpoints{Control, Info} with one Stop
- Windows unsupported for now (a pipe has no mode); warned once

Tests: identity lookups and shapes; API filter, status codes, panic
recovery, socket mode, rebind, cancellation; config parsing incl. bare
0660; Wire transitions; e2e of two nebulas with the info API resolving
the peer by address and port, through shared test.HTTPDo/HTTPGet helpers.
Goldens gain identity.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-21 12:26:09 -04:00

514 lines
20 KiB
Go

//go:build !windows
package localapi
import (
"bufio"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/slackhq/nebula/commands"
"github.com/slackhq/nebula/config"
"github.com/slackhq/nebula/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/unix"
)
type whoamiFlags struct {
Name string
Shout bool
}
// infoRegistry holds one identity command, whoami, whose -name selects an outcome, and
// one command the info API must withhold.
func infoRegistry() *commands.Registry {
reg := commands.New()
reg.Register(&commands.Command{
Name: "whoami",
ShortDescription: "reports who a name belongs to",
Identity: true,
Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError)
s := &whoamiFlags{}
fl.StringVar(&s.Name, "name", "", "the name to look up")
fl.BoolVar(&s.Shout, "shout", false, "answers in capitals")
return fl, s
},
Run: func(fs any, a []string) (commands.Result, error) {
if f := fs.(*whoamiFlags); f.Shout {
return commands.Value(map[string]any{"name": strings.ToUpper(f.Name)}), nil
}
switch name := fs.(*whoamiFlags).Name; name {
case "":
return commands.Value(map[string]any{"name": "me", "groups": []string{}}), nil
case "nobody":
return commands.NotFound("No active tunnel for vpn addr: %s", name), nil
case "bad":
return commands.Failed("The provided vpn addr could not be parsed: %s", name), nil
case "boom":
panic("this command is broken")
case "err":
return commands.Result{}, errors.New("an internal detail")
case "silent":
return commands.Result{}, nil
case "done":
return commands.Nothing(), nil
default:
return commands.Value(map[string]any{"name": name}), nil
}
},
})
reg.Register(echoCommand())
return reg
}
func serveInfo(t *testing.T, reg *commands.Registry, mode os.FileMode) (*InfoServer, string) {
t.Helper()
path := testSocket(t)
s := NewInfoServer(test.NewLogger(), reg)
require.NoError(t, s.Rebind(context.Background(), path, Access{Mode: mode}))
t.Cleanup(s.Stop)
return s, path
}
// httpDo sends one request over the socket at path and returns the status, body and
// headers. The host in the URL is a placeholder: the dialer ignores it.
func TestInfoServesIdentityCommands(t *testing.T) {
_, path := serveInfo(t, infoRegistry(), 0600)
status, body, hdr := test.HTTPDo(t, path, http.MethodGet, "/v1/whoami?name=alice")
assert.Equal(t, http.StatusOK, status)
assert.JSONEq(t, `{"name":"alice"}`, body)
assert.Equal(t, "application/json", hdr.Get("Content-Type"))
status, body = test.HTTPGet(t, path, "/v1/whoami")
assert.Equal(t, http.StatusOK, status)
assert.JSONEq(t, `{"name":"me","groups":[]}`, body, "a parameter left out is the command's default")
}
// TestInfoWithholdsTheRest checks the filter at the wire: a command that exists but is
// not an identity command is refused with the reason, and a name nothing is registered
// under is unknown, so a caller can tell a typo from a closed door.
func TestInfoWithholdsTheRest(t *testing.T) {
_, path := serveInfo(t, infoRegistry(), 0600)
status, body := test.HTTPGet(t, path, "/v1/echo?x=1")
assert.Equal(t, http.StatusForbidden, status)
assert.Contains(t, body, "echo is not available on this surface")
assert.Contains(t, body, "identity lookups only")
status, body = test.HTTPGet(t, path, "/v1/nope")
assert.Equal(t, http.StatusNotFound, status)
assert.Contains(t, body, `unknown command \"nope\"`)
for _, target := range []string{"/", "/v2/whoami", "/v1/", "/v1/whoami/extra", "//v1/whoami", "/v1/x/../whoami", "/v1/../v1/whoami"} {
status, body = test.HTTPGet(t, path, target)
assert.Equal(t, http.StatusNotFound, status, target)
assert.Contains(t, body, `"error"`, "%s: misses are JSON too", target)
}
}
func TestInfoStatusCodes(t *testing.T) {
_, path := serveInfo(t, infoRegistry(), 0600)
for _, tc := range []struct {
target string
status int
body string
}{
{"/v1/whoami?name=nobody", http.StatusNotFound, `{"error":"No active tunnel for vpn addr: nobody"}`},
{"/v1/whoami?name=bad", http.StatusBadRequest, `{"error":"The provided vpn addr could not be parsed: bad"}`},
{"/v1/whoami?nope=1", http.StatusBadRequest, `{"error":"whoami has no parameter \"nope\""}`},
{"/v1/whoami?name=a&name=b", http.StatusBadRequest, `{"error":"parameter \"name\" was given more than once"}`},
{"/v1/whoami?pretty=maybe", http.StatusBadRequest, `{"error":"pretty: expected a boolean, got \"maybe\""}`},
// A pair that does not decode is a malformed request, not a request without
// the pair: the lenient parser would drop it and keep the rest.
{"/v1/whoami?name=%zz", http.StatusBadRequest, `{"error":"malformed query string: invalid URL escape \"%zz\""}`},
{"/v1/whoami?name=a;shout", http.StatusBadRequest, `{"error":"malformed query string: invalid semicolon separator in query"}`},
// A boolean parameter with no value is set, as -shout is on a command line.
{"/v1/whoami?name=alice&shout", http.StatusOK, `{"name":"ALICE"}`},
{"/v1/whoami?name=alice&shout=false", http.StatusOK, `{"name":"alice"}`},
// Run failed: the caller hears that it did and no more; the detail is for the log.
{"/v1/whoami?name=err", http.StatusInternalServerError, `{"error":"command failed"}`},
// A refusal with no reason, the zero Result, fails the same way rather than
// passing as a success with nothing to say.
{"/v1/whoami?name=silent", http.StatusInternalServerError, `{"error":"command failed"}`},
} {
status, body := test.HTTPGet(t, path, tc.target)
assert.Equal(t, tc.status, status, tc.target)
assert.JSONEq(t, tc.body, body, tc.target)
}
// A success with nothing to say is the one empty reply.
status, body := test.HTTPGet(t, path, "/v1/whoami?name=done")
assert.Equal(t, http.StatusNoContent, status)
assert.Empty(t, body)
status, body, hdr := test.HTTPDo(t, path, http.MethodPost, "/v1/whoami?name=alice")
assert.Equal(t, http.StatusMethodNotAllowed, status)
assert.Equal(t, http.MethodGet, hdr.Get("Allow"))
assert.Contains(t, body, "read only")
status, _, _ = test.HTTPDo(t, path, http.MethodDelete, "/v1")
assert.Equal(t, http.StatusMethodNotAllowed, status)
}
// TestInfoPanicDoesNotKillTheDaemon: a broken command costs a request, not the overlay.
// This surface may be open to anyone on the host, so that holds against them too.
func TestInfoPanicDoesNotKillTheDaemon(t *testing.T) {
_, path := serveInfo(t, infoRegistry(), 0600)
status, body := test.HTTPGet(t, path, "/v1/whoami?name=boom")
assert.Equal(t, http.StatusInternalServerError, status)
assert.JSONEq(t, `{"error":"command failed"}`, body)
status, body = test.HTTPGet(t, path, "/v1/whoami?name=alice")
assert.Equal(t, http.StatusOK, status)
assert.JSONEq(t, `{"name":"alice"}`, body)
}
func TestInfoDescribe(t *testing.T) {
_, path := serveInfo(t, infoRegistry(), 0600)
status, body := test.HTTPGet(t, path, "/v1")
require.Equal(t, http.StatusOK, status)
var schema []commands.CommandSchema
require.NoError(t, json.Unmarshal([]byte(body), &schema))
var names []commands.Name
flags := map[commands.Name][]string{}
for _, c := range schema {
names = append(names, c.Name)
for _, f := range c.Flags {
flags[c.Name] = append(flags[c.Name], f.Name)
}
}
assert.ElementsMatch(t, []commands.Name{"help", "whoami"}, names, "only what is served is described")
assert.Equal(t, []string{"name", "shout", commands.FlagJSON, commands.FlagPretty}, flags["whoami"])
}
// TestInfoPresentationFlags: the query string takes -json and -pretty as a command line
// would. The answer is JSON either way; pretty indents it.
func TestInfoPresentationFlags(t *testing.T) {
_, path := serveInfo(t, infoRegistry(), 0600)
_, compact := test.HTTPGet(t, path, "/v1/whoami?name=alice&json=true")
//nolint:testifylint // the exact bytes, newline included, are what is under test
assert.Equal(t, "{\"name\":\"alice\"}\n", compact)
_, pretty := test.HTTPGet(t, path, "/v1/whoami?name=alice&pretty")
//nolint:testifylint // the layout is what is under test
assert.Equal(t, "{\n \"name\": \"alice\"\n}\n", pretty)
_, off := test.HTTPGet(t, path, "/v1/whoami?name=alice&pretty=false")
assert.Equal(t, compact, off)
}
// TestInfoSocketMode: the mode is the operator's, pinned regardless of the umask, and a
// changed mode on a reload is applied in place rather than by rebinding.
func TestInfoSocketMode(t *testing.T) {
old := unix.Umask(0077)
defer unix.Umask(old)
s, path := serveInfo(t, infoRegistry(), 0660)
fi, err := os.Stat(path)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0660), fi.Mode().Perm())
require.NoError(t, s.Rebind(context.Background(), path, Access{Mode: 0640}))
fi, err = os.Stat(path)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0640), fi.Mode().Perm())
assert.Equal(t, path, s.Path())
status, _ := test.HTTPGet(t, path, "/v1/whoami")
assert.Equal(t, http.StatusOK, status, "the same listener is still serving")
}
// TestInfoModeChangeRefusesASwappedSocket: whoever can write the directory can replace
// the node with a link to a file of their choosing; the daemon must not set the
// operator's mode on that file. The node is seen to be gone, the link is refused by the
// stale check, the file is untouched, and the endpoint reports itself down, since
// nothing of ours is reachable at the path.
func TestInfoModeChangeRefusesASwappedSocket(t *testing.T) {
s, path := serveInfo(t, infoRegistry(), 0600)
target := filepath.Join(filepath.Dir(path), "target")
require.NoError(t, os.WriteFile(target, nil, 0600))
require.NoError(t, os.Remove(path))
require.NoError(t, os.Symlink(target, path))
err := s.Rebind(context.Background(), path, Access{Mode: 0666})
require.ErrorContains(t, err, "is not a socket")
fi, err := os.Stat(target)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0600), fi.Mode().Perm(), "the file behind the link keeps its mode")
assert.Empty(t, s.Path(), "nothing of ours answers at the path, so nothing is served")
}
func TestInfoRebindMovesAndStopRemoves(t *testing.T) {
s, first := serveInfo(t, infoRegistry(), 0600)
second := testSocket(t)
require.NoError(t, s.Rebind(context.Background(), second, Access{Mode: 0600}))
assert.Equal(t, second, s.Path())
_, err := os.Stat(first)
assert.True(t, os.IsNotExist(err), "the old socket is removed")
status, body := test.HTTPGet(t, second, "/v1/whoami?name=alice")
assert.Equal(t, http.StatusOK, status)
assert.JSONEq(t, `{"name":"alice"}`, body)
s.Stop()
assert.Empty(t, s.Path())
_, err = os.Stat(second)
assert.True(t, os.IsNotExist(err), "Stop removes the socket")
}
func TestInfoRebindFailureKeepsTheOldSocket(t *testing.T) {
s, first := serveInfo(t, infoRegistry(), 0600)
err := s.Rebind(context.Background(), filepath.Join(privateBase(t), "missing", "n", "s.sock"), Access{Mode: 0600})
require.Error(t, err)
assert.Equal(t, first, s.Path())
status, _ := test.HTTPGet(t, first, "/v1/whoami")
assert.Equal(t, http.StatusOK, status)
}
func TestInfoContextCancellationStops(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
path := testSocket(t)
s := NewInfoServer(test.NewLogger(), infoRegistry())
require.NoError(t, s.Rebind(ctx, path, Access{Mode: 0600}))
t.Cleanup(s.Stop)
cancel()
require.Eventually(t, func() bool { return s.Path() == "" }, time.Second, 10*time.Millisecond)
_, err := os.Stat(path)
assert.True(t, os.IsNotExist(err))
// And a reload that lands during shutdown must not bring it back.
err = s.Rebind(ctx, path, Access{Mode: 0600})
require.Error(t, err)
assert.ErrorContains(t, err, "shutting down")
}
func TestInfoConfig(t *testing.T) {
load := func(t *testing.T, yaml string) (infoConfig, error) {
t.Helper()
c := config.NewC(test.NewLogger())
require.NoError(t, c.LoadString(yaml))
return infoConfigFrom(c)
}
cfg, err := load(t, "local_api: {}\n")
require.NoError(t, err)
assert.False(t, cfg.Enabled)
cfg, err = load(t, "local_api:\n info:\n socket: /run/nebula/info.sock\n")
require.NoError(t, err)
assert.False(t, cfg.Enabled, "a socket alone does not enable it")
_, err = load(t, "local_api:\n info:\n enabled: true\n")
require.Error(t, err)
require.ErrorContains(t, err, "needs a socket path")
cfg, err = load(t, "local_api:\n info:\n enabled: true\n socket: /run/nebula/info.sock\n")
require.NoError(t, err)
assert.Equal(t, infoConfig{Enabled: true, Path: "/run/nebula/info.sock", Access: Access{Mode: 0600}}, cfg)
cfg, err = load(t, "local_api:\n info:\n enabled: true\n socket: /run/nebula/info.sock\n socket_mode: \"0660\"\n")
require.NoError(t, err)
assert.Equal(t, infoConfig{Enabled: true, Path: "/run/nebula/info.sock", Access: Access{Mode: 0660}}, cfg)
_, err = load(t, "local_api:\n info:\n enabled: true\n socket: run/info.sock\n")
require.Error(t, err, "a relative socket path is refused rather than resolved against anything")
require.ErrorContains(t, err, "must be an absolute path")
require.ErrorContains(t, err, InfoSocketKey)
_, err = load(t, "local_api:\n info:\n enabled: true\n socket: /run/nebula/info.sock\n socket_mode: 0660\n")
require.Error(t, err, "a bare number is whichever integer the yaml parser made of it")
require.ErrorContains(t, err, "quoted octal string")
for _, bad := range []string{`"abc"`, `"1777"`, `"08"`} {
_, err = load(t, "local_api:\n info:\n enabled: true\n socket: /run/nebula/info.sock\n socket_mode: "+bad+"\n")
require.Error(t, err, bad)
require.ErrorContains(t, err, "not an octal file mode", bad)
}
_, err = load(t, fmt.Sprintf("local_api:\n info:\n enabled: true\n socket: /%s\n", strings.Repeat("x", sunPathMax)))
require.Error(t, err)
assert.ErrorContains(t, err, "too long")
}
// TestWireInfoFollowsConfig: off by default, up when enabled, down when disabled, and
// never on the control socket's path.
func TestWireInfoFollowsConfig(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
ctlPath := testSocket(t)
infoPath := testSocket(t)
sectionWithMode := func(enabled bool, socket, mode string) string {
return fmt.Sprintf("local_api:\n control:\n socket: %s\n info:\n enabled: %t\n socket: %s\n socket_mode: \"%s\"\n", ctlPath, enabled, socket, mode)
}
section := func(enabled bool, socket string) string {
return sectionWithMode(enabled, socket, "0600")
}
c := config.NewC(test.NewLogger())
require.NoError(t, c.LoadString(section(false, infoPath)))
var logs lockedBuffer
eps := Wire(ctx, test.NewLoggerWithOutput(&logs), c, infoRegistry())
t.Cleanup(eps.Stop)
assert.Equal(t, ctlPath, eps.Control.Path())
assert.Empty(t, eps.Info.Path(), "off until enabled")
require.NoError(t, c.ReloadConfigString(section(true, infoPath)).Err)
assert.Equal(t, infoPath, eps.Info.Path())
status, body := test.HTTPGet(t, infoPath, "/v1/whoami?name=alice")
assert.Equal(t, http.StatusOK, status)
assert.JSONEq(t, `{"name":"alice"}`, body)
// A reload that changes nothing leaves it where it is.
require.NoError(t, c.ReloadConfigString(section(true, infoPath)).Err)
assert.Equal(t, infoPath, eps.Info.Path())
// A reload that changes only the mode applies it in place.
require.NoError(t, c.ReloadConfigString(sectionWithMode(true, infoPath, "0660")).Err)
assert.Equal(t, infoPath, eps.Info.Path())
fi, err := os.Stat(infoPath)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0660), fi.Mode().Perm())
// A reload that moves the socket moves it, and removes the old one.
moved := testSocket(t)
require.NoError(t, c.ReloadConfigString(section(true, moved)).Err)
assert.Equal(t, moved, eps.Info.Path())
_, err = os.Stat(infoPath)
assert.True(t, os.IsNotExist(err))
status, body = test.HTTPGet(t, moved, "/v1/whoami?name=bob")
assert.Equal(t, http.StatusOK, status)
assert.JSONEq(t, `{"name":"bob"}`, body)
require.NoError(t, c.ReloadConfigString(section(true, infoPath)).Err)
require.Equal(t, infoPath, eps.Info.Path())
// The control socket's path is refused, and the running endpoint is kept, which is
// what the log says.
require.NoError(t, c.ReloadConfigString(section(true, ctlPath)).Err)
assert.Equal(t, infoPath, eps.Info.Path())
assert.Equal(t, ctlPath, eps.Control.Path())
assert.Contains(t, logs.String(), "keeping the current endpoint")
assert.NotContains(t, logs.String(), "Local info API is not available")
require.NoError(t, c.ReloadConfigString(section(false, infoPath)).Err)
assert.Empty(t, eps.Info.Path())
_, err = os.Stat(infoPath)
assert.True(t, os.IsNotExist(err))
// One Stop takes both down.
require.NoError(t, c.ReloadConfigString(section(true, infoPath)).Err)
eps.Stop()
assert.Empty(t, eps.Info.Path())
assert.Empty(t, eps.Control.Path())
}
// TestWireInfoKeepsRunningOnABadReload: a typo in the section must not cut off the
// programs relying on the endpoint; the operator is told and the endpoint stays.
func TestWireInfoKeepsRunningOnABadReload(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
infoPath := testSocket(t)
good := fmt.Sprintf("local_api:\n info:\n enabled: true\n socket: %s\n", infoPath)
c := config.NewC(test.NewLogger())
require.NoError(t, c.LoadString(good))
srv := wireInfo(ctx, test.NewLogger(), c, infoRegistry())
t.Cleanup(srv.Stop)
require.Equal(t, infoPath, srv.Path())
require.NoError(t, c.ReloadConfigString(good+" socket_mode: 0660\n").Err)
assert.Equal(t, infoPath, srv.Path())
status, _ := test.HTTPGet(t, infoPath, "/v1/whoami")
assert.Equal(t, http.StatusOK, status)
}
// rawInfoRequest opens the socket and writes req as it is, so a test can send what an
// HTTP client would not: a body that never arrives. It returns the connection.
func rawInfoRequest(t *testing.T, path, req string) net.Conn {
t.Helper()
conn, err := net.DialTimeout("unix", path, time.Second)
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
_, err = conn.Write([]byte(req))
require.NoError(t, err)
return conn
}
// TestInfoRefusesARequestBody: a request that announces a body is answered 400 at once,
// before the body arrives, and the connection is closed once the read deadline has ended
// the wait for it, so a client that never sends the body holds its slot for the deadline
// and no longer.
func TestInfoRefusesARequestBody(t *testing.T) {
oldHeader, oldRead := infoReadHeaderTimeout, infoReadTimeout
infoReadHeaderTimeout, infoReadTimeout = 200*time.Millisecond, 300*time.Millisecond
t.Cleanup(func() { infoReadHeaderTimeout, infoReadTimeout = oldHeader, oldRead })
_, path := serveInfo(t, infoRegistry(), 0600)
start := time.Now()
conn := rawInfoRequest(t, path, "GET /v1/whoami HTTP/1.1\r\nHost: nebula\r\nContent-Length: 10\r\n\r\n")
require.NoError(t, conn.SetReadDeadline(time.Now().Add(2*time.Second)))
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
require.NoError(t, err)
body, _ := io.ReadAll(res.Body)
res.Body.Close()
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
assert.Contains(t, string(body), "takes no request body")
assert.True(t, res.Close, "the connection is not kept for another request")
assert.Less(t, time.Since(start), 200*time.Millisecond, "answered before the body, which never comes")
_, err = conn.Read(make([]byte, 1))
require.ErrorIs(t, err, io.EOF, "the server closed the connection")
assert.Less(t, time.Since(start), time.Second, "and did so at the read deadline, not later")
}
// TestInfoDropsASlowClient: a client that sends its headers and then nothing loses its
// connection at the read timeout rather than holding a slot for as long as it likes.
func TestInfoDropsASlowClient(t *testing.T) {
oldHeader, oldRead := infoReadHeaderTimeout, infoReadTimeout
infoReadHeaderTimeout, infoReadTimeout = 200*time.Millisecond, 300*time.Millisecond
t.Cleanup(func() { infoReadHeaderTimeout, infoReadTimeout = oldHeader, oldRead })
_, path := serveInfo(t, infoRegistry(), 0600)
// Half a request line, then silence: the header deadline ends it.
conn := rawInfoRequest(t, path, "GET /v1/whoami HTTP/1.1\r\nHost: neb")
require.NoError(t, conn.SetReadDeadline(time.Now().Add(2*time.Second)))
start := time.Now()
_, err := conn.Read(make([]byte, 1))
require.Error(t, err, "the server closed the connection")
assert.Less(t, time.Since(start), time.Second)
// And the server still answers the next client.
status, _ := test.HTTPGet(t, path, "/v1/whoami")
assert.Equal(t, http.StatusOK, status)
}