Files
nebula/localapi/info.go
T
Matt RichardsonandClaude Fable 5.1 aa04cc446a Drop the flag that said the info API was not served on windows
It is served there now, on a pipe, so nothing set the flag any more. It
was also a plain bool written by the first reconcile and by the reload
callback, which was the review's point about it.

- infoSupported, errInfoUnsupported, the Rebind check and the reload
  branch that warned once go

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

453 lines
17 KiB
Go

package localapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"maps"
"net"
"net/http"
"net/url"
"os"
"path"
"runtime"
"runtime/debug"
"slices"
"strconv"
"time"
"github.com/slackhq/nebula/commands"
"github.com/slackhq/nebula/config"
"golang.org/x/net/netutil"
)
// The info API's config keys. It is off until InfoEnabledKey is true and InfoSocketKey
// names a socket; nothing is derived, because the programs that will dial it have to be
// told where it is anyway.
const (
InfoEnabledKey = "local_api.info.enabled"
InfoSocketKey = "local_api.info.socket"
InfoModeKey = "local_api.info.socket_mode"
InfoGroupKey = "local_api.info.group"
)
const (
// defaultInfoMode is the socket's mode when the config names neither a mode nor a
// group: the daemon's user alone, so that enabling the API does not open it to
// anyone until the operator says who. Naming a group makes it groupSocketMode.
defaultInfoMode os.FileMode = 0600
// infoMaxConns bounds the connections held open at once. Beyond it the kernel
// queues, which is what an HTTP client expects of a busy server; the control socket
// refuses instead because its clients are people at a prompt.
infoMaxConns = 64
infoWriteTimeout = 30 * time.Second
infoIdleTimeout = 60 * time.Second
infoMaxHeaderBytes = 64 * 1024
)
// The read deadlines are variables so a test can shorten them. infoReadTimeout bounds
// the whole request, not only its headers: a request is a GET with a query string and
// the handlers never read a body, but net/http drains an unread body after the handler
// returns, and without a deadline on that read a client sending a body a byte at a time
// would hold its connection slot for as long as it liked, and sixty-four of them would
// hold every slot. A body is refused as well, see refuseBody, but net/http drains it
// before it closes, so this deadline is what ends a client that announced a body and
// never sent it.
var (
infoReadHeaderTimeout = 10 * time.Second
infoReadTimeout = 15 * time.Second
)
// servedOnInfo decides what the info API will run: the commands that report identity
// and nothing else, which the commands declare about themselves. Filter adds help to
// the result, so a client can ask what is served; help on a filtered registry describes
// only that registry.
func servedOnInfo(c *commands.Command) bool {
return c.Identity
}
// notServedOnInfo explains a refusal, naming the surfaces that will run the command.
const notServedOnInfo = "the info API serves identity lookups only; the ssh debug server and nebula ctl serve the rest"
// InfoServer serves the identity commands over HTTP, on a socket an operator may open to
// other users on the host.
//
// It is the second local API, and the opposite of the control socket in who it is for.
// The control socket is for the user who runs nebula, serves nearly everything and needs
// no configuration. This one is for every other program on the machine, a web server
// that wants to know who is on the other end of a connection arriving over the overlay,
// say, and it is off until an operator names a socket and a mode for it. What it serves
// is decided by the commands themselves, Command.Identity: the identity a certificate
// asserts and nothing operational, so opening it to a group of services, or to everyone
// on the host, gives away nothing the overlay did not already tell them.
//
// HTTP, so that no client library is needed. GET /v1/<command>?<flag>=<value> runs a
// command with its options as the query string and answers with the command's JSON, the
// same value the control socket and -json produce. GET /v1 describes what is served. A
// refusal is a 4xx with {"error": reason}: 404 when the command said the thing asked
// about does not exist (commands.NotFound), 400 for a parameter or an argument the
// command would not take, 403 for a command this surface withholds.
type InfoServer struct {
endpoint
// reg is the identity commands this surface serves, filtered from the daemon's
// registry when the server was built.
reg *commands.Registry
}
// NewInfoServer returns an InfoServer that serves the identity commands in reg. reg is
// complete when it arrives: nebula registers every command before it wires its local
// APIs, and nothing registers one later, so the filter is taken once here rather than
// kept up to date against the source.
func NewInfoServer(l *slog.Logger, reg *commands.Registry) *InfoServer {
return &InfoServer{
endpoint: endpoint{l: l, name: "local info API"},
reg: reg.Filter(servedOnInfo, notServedOnInfo),
}
}
// Rebind brings the server up at path with mode, moves it there, or, when it is already
// there, applies a changed mode; endpoint.rebindLocked says what that promises. It is
// the one way the server comes up, so Wire's reload callback and its first attempt are
// the same call.
func (s *InfoServer) Rebind(ctx context.Context, path string, acc Access) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.rebindLocked(ctx, path, acc, func(ln net.Listener) io.Closer {
srv := &http.Server{
Handler: s.handler(),
ReadHeaderTimeout: infoReadHeaderTimeout,
ReadTimeout: infoReadTimeout,
WriteTimeout: infoWriteTimeout,
IdleTimeout: infoIdleTimeout,
MaxHeaderBytes: infoMaxHeaderBytes,
ErrorLog: slog.NewLogLogger(s.l.Handler(), slog.LevelDebug),
}
s.l.Info("Local info API listening", "path", path, "access", acc.String())
go s.serve(srv, ln)
// Closing the server closes the listener Serve was handed, and a Serve that has
// not started yet closes it on its way out, so the node is never left bound by a
// listener nothing is accepting from. Requests in flight are cut off rather than
// waited for: a reload must not hang on a slow client, and the answer is a lookup
// the client can simply repeat.
return srv
})
}
// serve runs the HTTP server on ln until it is closed. Only an error other than the
// close is worth a line: Stop and Rebind are the two things that close it.
func (s *InfoServer) serve(srv *http.Server, ln net.Listener) {
err := srv.Serve(netutil.LimitListener(ln, infoMaxConns))
if err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
s.l.Error("Local info API stopped serving", "error", err)
}
}
// handler builds the routes. Every answer is JSON, the misses included, so a client
// never has to parse net/http's text for a path or method it did not expect. That
// includes a path ServeMux would clean and redirect, /v1/../v1/x or //v1/x, which is
// refused outright: nothing legitimate sends one, and a redirect is one more thing a
// client would have to follow.
func (s *InfoServer) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /v1", s.handleDescribe)
mux.HandleFunc("GET /v1/{command}", s.handleCommand)
mux.HandleFunc("/v1", methodNotAllowed)
mux.HandleFunc("/v1/{command}", methodNotAllowed)
mux.HandleFunc("/", notFoundRoute)
return s.recovering(refuseBody(mux))
}
// refuseBody answers a request that carries a body with 400, before any handler sees
// it, and has the connection closed after the reply rather than kept for another
// request. Nothing here reads a body; one is a mistake, and the client is told what to
// do instead. net/http still drains what the body it announced it would send, up to the
// read deadline, so the deadline is what bounds a client that never sends it.
func refuseBody(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ContentLength != 0 || len(r.TransferEncoding) > 0 {
w.Header().Set("Connection", "close")
writeJSONError(w, http.StatusBadRequest, "the info API takes no request body; put the command's parameters in the query string")
return
}
next.ServeHTTP(w, r)
})
}
// recovering turns a panic in a command into a 500 and a log line rather than a dropped
// connection, which is all net/http would offer, and keeps the daemon up, which is what
// matters: this surface is reachable by whoever the operator opened it to. The handlers
// produce their whole answer before writing any of it, so a panic in Run always finds
// the response unwritten.
func (s *InfoServer) recovering(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
s.l.Error("Recovered from panic serving a local info API request",
"panic", rec,
"path", r.URL.Path,
"query", r.URL.RawQuery,
"stack", string(debug.Stack()),
)
writeJSONError(w, http.StatusInternalServerError, "command failed")
}
}()
if path.Clean(r.URL.Path) != r.URL.Path {
notFoundRoute(w, r)
return
}
next.ServeHTTP(w, r)
})
}
// handleDescribe answers GET /v1 with the schema of every command served here, the same
// document the control socket's describe returns for its own surface.
func (s *InfoServer) handleDescribe(w http.ResponseWriter, _ *http.Request) {
body, err := json.Marshal(s.reg.Schema())
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, body)
}
// handleCommand answers GET /v1/<command>: the query string carries the command's
// parameters, and the response body is the command's JSON. A request body is never
// read. It is the control socket's serve with HTTP's vocabulary for the outcomes.
func (s *InfoServer) handleCommand(w http.ResponseWriter, r *http.Request) {
name := commands.Name(r.PathValue("command"))
reg := s.reg
cmd, err := reg.Resolve(name)
if err != nil {
// A command this surface withholds is a different answer from one that does not
// exist, and Resolve says which; only the status is picked here.
status := http.StatusNotFound
var withheld *commands.WithheldError
if errors.As(err, &withheld) {
status = http.StatusForbidden
}
writeJSONError(w, status, err.Error())
return
}
// Parsed strictly: the lenient parser on the request drops a pair it cannot decode
// and keeps the rest, and a request that lost its address would be answered as one
// that never had it, which for print-cert is this node's own certificate.
q, err := url.ParseQuery(r.URL.RawQuery)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "malformed query string: "+err.Error())
return
}
params, format, err := queryParams(q, reg.SchemaFor(cmd))
if err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
res, err := commands.Invoke(commands.Request{Command: cmd, Params: params})
if err != nil {
var pe *commands.ParamError
if errors.As(err, &pe) {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
// Run failed. The sentence is for the operator's log; the caller, who may be
// anyone the operator admitted, is told that it failed and no more.
s.l.Warn("Local info API command failed", "command", name, "error", err)
writeJSONError(w, http.StatusInternalServerError, "command failed")
return
}
// A command that acts does so before this surface's one reply, whose body is the
// outcome; see Command.Then.
if cmd.Then != nil && res.OK {
res = cmd.Then()
}
// A refusal is answered before an empty Result is: the zero Result is a refusal with
// nothing to say, which exits 1 over ssh and must not read as success here. One with
// no reason is the command's fault, as a Run error is, and is answered the same way.
if !res.OK {
if res.Reason == "" {
s.l.Warn("Local info API command refused without a reason", "command", name)
writeJSONError(w, http.StatusInternalServerError, "command failed")
return
}
status := http.StatusBadRequest
if res.NotFound {
status = http.StatusNotFound
}
writeJSONError(w, status, res.Reason)
return
}
if res.Empty() {
w.WriteHeader(http.StatusNoContent)
return
}
// The same rendering function every surface uses, so the value here is byte for
// byte what -json prints at an ssh prompt.
buf := &bytes.Buffer{}
if err := commands.Render(cmd, res, format, buf); err != nil {
s.l.Warn("Local info API could not render a result", "command", name, "error", err)
writeJSONError(w, http.StatusInternalServerError, "command failed")
return
}
writeJSON(w, http.StatusOK, buf.Bytes())
}
// queryParams reads a query string as a command's parameters. Each key is a flag of the
// command, by name, in the form the control socket takes them; one given twice is
// refused rather than silently picking either. The presentation flags are read as a
// command line reads them, and set apart: the answer is JSON regardless, and pretty
// indents it.
func queryParams(q url.Values, schema commands.CommandSchema) (map[string]string, commands.Format, error) {
var params, presentation map[string]string
for _, k := range slices.Sorted(maps.Keys(q)) {
vs := q[k]
if len(vs) > 1 {
return nil, commands.FormatUnset, fmt.Errorf("parameter %q was given more than once", k)
}
into := &params
if k == commands.FlagJSON || k == commands.FlagPretty {
into = &presentation
}
if *into == nil {
*into = map[string]string{}
}
v := vs[0]
// ?raw is ?raw=true, as -raw is -raw=true on a command line.
if v == "" && isBoolParam(schema, k) {
v = "true"
}
(*into)[k] = v
}
format, err := commands.PresentationFormat(presentation)
if err != nil {
return nil, commands.FormatUnset, err
}
if format == commands.FormatUnset {
format = commands.FormatJSON
}
return params, format, nil
}
// isBoolParam reports whether name is one of the command's boolean flags.
func isBoolParam(schema commands.CommandSchema, name string) bool {
for _, f := range schema.Flags {
if f.Name == name {
return f.Bool
}
}
return false
}
func methodNotAllowed(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Allow", http.MethodGet)
writeJSONError(w, http.StatusMethodNotAllowed, fmt.Sprintf("%s is not allowed; the info API is read only, use GET", r.Method))
}
func notFoundRoute(w http.ResponseWriter, r *http.Request) {
writeJSONError(w, http.StatusNotFound, fmt.Sprintf("no such path %s; GET /v1 lists the commands and GET /v1/<command> runs one", r.URL.Path))
}
func writeJSON(w http.ResponseWriter, status int, body []byte) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
w.WriteHeader(status)
_, _ = w.Write(body)
}
func writeJSONError(w http.ResponseWriter, status int, msg string) {
body, _ := json.Marshal(map[string]string{"error": msg})
writeJSON(w, status, append(body, '\n'))
}
// infoConfig is the info API section of a config, read and checked.
type infoConfig struct {
Enabled bool
Path string
Access Access
}
// infoConfigFrom reads the info API section. A disabled section needs nothing else to be
// right; an enabled one needs a socket path, a group that exists if it names one, and
// a mode that parses if it names one. A group with no mode means groupSocketMode, and a
// mode that gives a named group nothing is refused: it would be a group that cannot
// connect, which is not what naming one asks for.
func infoConfigFrom(c *config.C) (infoConfig, error) {
cfg := infoConfig{Enabled: c.GetBool(InfoEnabledKey, false)}
if !cfg.Enabled {
return cfg, nil
}
p := c.GetString(InfoSocketKey, "")
if p == "" {
return cfg, fmt.Errorf("%s is true but %s is not set; the info API needs a socket path", InfoEnabledKey, InfoSocketKey)
}
if err := checkSocketPath(InfoSocketKey, p); err != nil {
return cfg, err
}
cfg.Path = p
group, err := groupFromConfig(c, InfoGroupKey)
if err != nil {
return cfg, err
}
def := defaultInfoMode
if group != nil {
def = groupSocketMode
}
mode, err := parseSocketMode(c.Get(InfoModeKey), def)
if err != nil {
return cfg, fmt.Errorf("%s: %w", InfoModeKey, err)
}
// On unix the mode is what admits the group, so one that shuts it out is a config
// error; on windows the group is an entry in the pipe's security descriptor and the
// mode means nothing to a pipe, so it is not held against the group there.
if runtime.GOOS != "windows" && group != nil && mode&0070 == 0 {
return cfg, fmt.Errorf("%s %s gives group %s no access to the socket; use \"%04o\" or leave the mode unset", InfoModeKey, modeString(mode), group.Name, mode|0060)
}
cfg.Access = Access{Mode: mode, Group: group}
return cfg, nil
}
// parseSocketMode reads an octal mode written as a string. A bare 0660 in yaml is an
// integer, and which integer depends on the parser (octal 432 under YAML 1.1, decimal
// 660 under 1.2), so a number is refused with the fix rather than quietly applied as
// whichever mode its digits happen to spell in octal.
func parseSocketMode(raw any, def os.FileMode) (os.FileMode, error) {
switch v := raw.(type) {
case nil:
return def, nil
case string:
mode, err := strconv.ParseUint(v, 8, 32)
if err != nil || mode&^0777 != 0 {
return 0, fmt.Errorf("%q is not an octal file mode such as \"0660\"", v)
}
return os.FileMode(mode), nil
default:
return 0, fmt.Errorf("must be a quoted octal string such as \"0660\", not the number %v; yaml reads a bare mode as a decimal or octal integer depending on the parser", v)
}
}