mirror of
https://github.com/slackhq/nebula.git
synced 2026-11-15 16:46:52 +01:00
Add a local control socket and nebula ctl (#1975)
Add support for accessing the previously ssh-only debug commands via `nebula ctl <command>.` - unix socket (named pipe on windows) for communication - sockets scoped to the user + an optional group - in cmd/nebula and cmd/nebula-service, but not nebula.Main Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
9a5bdb508e
commit
0f767ce529
54 files changed
+7581
-925
No files matched your search
@@ -0,0 +1,163 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/slackhq/nebula/commands"
|
||||
)
|
||||
|
||||
// DialTimeout bounds the connect to a socket that exists but is not being served.
|
||||
const DialTimeout = 5 * time.Second
|
||||
|
||||
// ResponseTimeout bounds the exchange once connected: sending the request and waiting
|
||||
// for the daemon's answer. The daemon drops a client that is slow to send in 10s and one
|
||||
// that is slow to read in 30s, so this is the other side of the same bargain, with room
|
||||
// for the slowest command (a hostmap dump on a large network) before a stalled daemon is
|
||||
// given up on. Client.Timeout overrides it.
|
||||
const ResponseTimeout = 60 * time.Second
|
||||
|
||||
// MaxResponseBytes bounds what a client will read back. A response carries one command's
|
||||
// output, and a hostmap dump on a large network is the realistic worst case.
|
||||
const MaxResponseBytes = 64 << 20
|
||||
|
||||
// Client talks to a nebula listening at Path.
|
||||
type Client struct {
|
||||
Path string
|
||||
|
||||
// Timeout bounds the request and response once connected. Zero means
|
||||
// ResponseTimeout.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// ReplyError is an answer the client could not read: the daemon was reached and wrote
|
||||
// something back, but not a response this client understands. The daemon is not
|
||||
// nebula, or not a version this client speaks to.
|
||||
type ReplyError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *ReplyError) Error() string { return "the daemon's reply could not be read: " + e.Err.Error() }
|
||||
func (e *ReplyError) Unwrap() error { return e.Err }
|
||||
|
||||
// DaemonError is a refusal the daemon answered with: the connection worked, the request
|
||||
// was read, and the daemon said no. It is told apart from a failure to reach the daemon
|
||||
// so that a client does not send its user after a socket path, or a sudo, when the daemon
|
||||
// was right there and answered. A protocol version the daemon does not speak, a socket at
|
||||
// its connection limit and a peer the daemon will not serve all arrive as one of these.
|
||||
type DaemonError struct {
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *DaemonError) Error() string { return e.Msg }
|
||||
|
||||
// Describe asks the daemon which commands it serves and what parameters they take, so a
|
||||
// client can offer them without having been built against this daemon.
|
||||
func (c *Client) Describe() ([]commands.CommandSchema, error) {
|
||||
res, err := c.do(&Request{Version: ProtocolVersion, Describe: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.Error != "" {
|
||||
return nil, &DaemonError{Msg: res.Error}
|
||||
}
|
||||
|
||||
schema := []commands.CommandSchema{}
|
||||
if err := json.Unmarshal(res.Result, &schema); err != nil {
|
||||
return nil, &ReplyError{Err: fmt.Errorf("could not parse the command schema: %w", err)}
|
||||
}
|
||||
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
// Invoke runs one command and returns what the daemon said. params are the command's
|
||||
// options by name, as the strings a command line parsed them from; a program speaking
|
||||
// the protocol directly may send JSON's own types instead, see Request.Params. An empty
|
||||
// format asks for JSON, which is the API's default; "text" asks the daemon to apply the
|
||||
// command's own human rendering.
|
||||
func (c *Client) Invoke(command string, params map[string]string, args []string, format string) (*Response, error) {
|
||||
var wire map[string]any
|
||||
if len(params) > 0 {
|
||||
wire = make(map[string]any, len(params))
|
||||
for k, v := range params {
|
||||
wire[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return c.do(&Request{
|
||||
Version: ProtocolVersion,
|
||||
Command: command,
|
||||
Params: wire,
|
||||
Args: args,
|
||||
Format: format,
|
||||
})
|
||||
}
|
||||
|
||||
// do sends one request and reads the one response.
|
||||
//
|
||||
// Nothing is checked about the socket before the connect: its mode is the kernel's to
|
||||
// enforce, and a socket that is not ours to connect to is refused there with a
|
||||
// permission error, which the caller turns into advice.
|
||||
func (c *Client) do(req *Request) (*Response, error) {
|
||||
conn, err := dial(c.Path, DialTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Without this a daemon that accepts and then stalls, blocked inside a command or
|
||||
// stopped with SIGSTOP, hangs the client for good, and a script wrapping it never
|
||||
// completes. One deadline covers the write and the read: the answer is due within
|
||||
// the budget, however the time is spent.
|
||||
timeout := c.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = ResponseTimeout
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
|
||||
// If we get a write error, save that fact but then look for a response from the server.
|
||||
// Some error conditions manifest as the server accepting our connection, writing an
|
||||
// error, and then closing the connection. Our write races against that close.
|
||||
writeErr := error(nil)
|
||||
if err := writeFrame(conn, req); err != nil {
|
||||
writeErr = fmt.Errorf("could not send request: %w", err)
|
||||
}
|
||||
|
||||
res, err := readResponse(conn)
|
||||
if err != nil {
|
||||
// Something answered and it was not a response: the daemon was reached, and
|
||||
// that is the message even when it hung up before the request was written,
|
||||
// which a daemon that is not nebula is likely to do.
|
||||
var fe *frameError
|
||||
if errors.As(err, &fe) {
|
||||
return nil, &ReplyError{Err: err}
|
||||
}
|
||||
if writeErr != nil {
|
||||
return nil, writeErr
|
||||
}
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
return nil, fmt.Errorf("the daemon did not answer within %s", timeout)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func readResponse(conn net.Conn) (*Response, error) {
|
||||
res := &Response{}
|
||||
if err := readFrame(conn, MaxResponseBytes, "response", res, false); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("the daemon closed the connection without answering")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
Reference in new issue
Block a user