| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426 |
- package localapi
- import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "log/slog"
- "maps"
- "net"
- "net/http"
- "net/url"
- "os"
- "path"
- "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
- )
- // errInfoUnsupported is returned by Rebind on a platform without a socket mode to open.
- var errInfoUnsupported = errors.New("the info API is not available on this platform")
- // 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
- src *commands.Registry
- }
- // NewInfoServer returns an InfoServer that will serve the identity commands in reg. reg
- // is kept rather than filtered here, for the same reason the control socket keeps its
- // registry: a command registered on it later is served too.
- func NewInfoServer(l *slog.Logger, reg *commands.Registry) *InfoServer {
- return &InfoServer{endpoint: endpoint{l: l, name: "local info API"}, src: reg}
- }
- // registry returns the commands this surface serves, filtered at the time of the call.
- func (s *InfoServer) registry() *commands.Registry {
- return s.src.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()
- if !infoSupported {
- return errInfoUnsupported
- }
- 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.registry().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"))
- cmd, err := s.registry().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
- }
- params, format, err := queryParams(r.URL.Query())
- 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
- }
- if cmd.AfterReply != nil {
- // The reply sits in the server's buffer until the handler returns or flushes;
- // AfterReply's contract is that the surface has delivered it, so flush first.
- // writeJSON sets Content-Length, so a flushed reply is complete to its reader.
- defer func() {
- _ = http.NewResponseController(w).Flush()
- cmd.AfterReply()
- }()
- }
- if res.Empty() {
- w.WriteHeader(http.StatusNoContent)
- return
- }
- if !res.OK {
- status := http.StatusBadRequest
- if res.NotFound {
- status = http.StatusNotFound
- }
- writeJSONError(w, status, res.Reason)
- 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) (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 := ¶ms
- if k == commands.FlagJSON || k == commands.FlagPretty {
- into = &presentation
- }
- if *into == nil {
- *into = map[string]string{}
- }
- (*into)[k] = vs[0]
- }
- format, err := commands.PresentationFormat(presentation)
- if err != nil {
- return nil, commands.FormatUnset, err
- }
- if format == commands.FormatUnset {
- format = commands.FormatJSON
- }
- return params, format, nil
- }
- 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)
- }
- path, err := configuredSocketPath(InfoSocketKey, p)
- if err != nil {
- return cfg, err
- }
- cfg.Path = path
- 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)
- }
- if 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)
- }
- }
|