diff --git a/command/commands.go b/command/commands.go index 287cdfaf1..ae770ee57 100644 --- a/command/commands.go +++ b/command/commands.go @@ -214,6 +214,11 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory { Meta: meta, }, nil }, + "curl": func() (cli.Command, error) { + return &CurlCommand{ + Meta: meta, + }, nil + }, // operator debug was released in 0.12 as debug. This top-level alias preserves compatibility "debug": func() (cli.Command, error) { return &OperatorDebugCommand{ diff --git a/command/curl.go b/command/curl.go new file mode 100644 index 000000000..8fe18a77e --- /dev/null +++ b/command/curl.go @@ -0,0 +1,436 @@ +package command + +import ( + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/hashicorp/go-cleanhttp" + "github.com/hashicorp/nomad/api" + "github.com/posener/complete" +) + +type CurlCommand struct { + Meta + + verboseFlag bool + method string + body io.Reader +} + +func (*CurlCommand) Help() string { + helpText := ` +Usage: nomad curl [options] + + curl is a utility command for accessing Nomad's HTTP API and is inspired by + the popular curl command line program. Nomad's curl command populates Nomad's + standard environment variables into their appropriate HTTP headers. If the + 'path' does not begin with "http" then $NOMAD_ADDR will be used. + + The 'path' can be in one of the following forms: + + /v1/allocations <- API Paths must start with a / + localhost:4646/v1/allocations <- Scheme will be inferred + https://localhost:4646/v1/allocations <- Scheme will be https:// + + Note that Nomad's curl does not always match the popular curl programs + behavior. Instead Nomad's curl is optimized for common Nomad HTTP API + operations. + +General Options: + + ` + generalOptionsUsage(usageOptsDefault) + ` + +Curl Specific Options: + + -dryrun + Output curl command to stdout and exit. + HTTP Basic Auth will never be output. If the $NOMAD_HTTP_AUTH environment + variable is set, it will be referenced in the appropriate curl flag in the + output. + ACL tokens set via the $NOMAD_TOKEN environment variable will only be + referenced by environment variable as with HTTP Basic Auth above. However + if the -token flag is explicitly used, the token will also be included in + the output. + + -H
+ Adds an additional HTTP header to the request. May be specified more than + once. These headers take precedent over automatically ones such as + X-Nomad-Token. + + -verbose + Output extra information to stderr similar to curl's --verbose flag. + + -X + HTTP method of request. Defaults to GET. +` + + return strings.TrimSpace(helpText) +} + +func (*CurlCommand) Synopsis() string { + return "Query Nomad's HTTP API like curl" +} + +func (c *CurlCommand) AutocompleteFlags() complete.Flags { + return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient), + complete.Flags{ + "-dryrun": complete.PredictNothing, + }) +} + +func (c *CurlCommand) AutocompleteArgs() complete.Predictor { + //TODO(schmichael) wouldn't it be cool to build path autocompletion off + // of our http mux? + return complete.PredictNothing +} + +func (*CurlCommand) Name() string { return "curl" } + +func (c *CurlCommand) Run(args []string) int { + var dryrun bool + headerFlags := newHeaderFlags() + + flags := c.Meta.FlagSet(c.Name(), FlagSetClient) + flags.Usage = func() { c.Ui.Output(c.Help()) } + flags.BoolVar(&dryrun, "dryrun", false, "") + flags.BoolVar(&c.verboseFlag, "verbose", false, "") + flags.StringVar(&c.method, "X", "", "") + flags.Var(headerFlags, "H", "") + + if err := flags.Parse(args); err != nil { + c.Ui.Error(fmt.Sprintf("Error parsing flags: %v", err)) + return 1 + } + args = flags.Args() + + if len(args) < 1 { + c.Ui.Error("A path or URL is required") + c.Ui.Error(commandErrorText(c)) + return 1 + } + + if n := len(args); n > 1 { + c.Ui.Error(fmt.Sprintf("curl accepts exactly 1 argument, but %d arguments were found", n)) + c.Ui.Error(commandErrorText(c)) + return 1 + } + + // By default verbose func is a noop + verbose := func(string, ...interface{}) {} + if c.verboseFlag { + verbose = func(format string, a ...interface{}) { + // Use Warn instead of Info because Info goes to stdout + c.Ui.Warn(fmt.Sprintf(format, a...)) + } + } + + // Opportunistically read from stdin and POST unless method has been + // explicitly set. + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) == 0 { + verbose("* Reading request body from stdin.") + c.body = os.Stdin + if c.method == "" { + c.method = "POST" + } + } else { + c.method = "GET" + } + + config := c.clientConfig() + + // NewClient mutates or validates Config.Address, so call it to match + // the behavior of other commands. + _, err := api.NewClient(config) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error initializing client: %v", err)) + return 1 + } + + path, err := pathToURL(config, args[0]) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error turning path into URL: %v", err)) + return 2 + } + + if dryrun { + out, err := c.apiToCurl(config, headerFlags.headers, path) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error creating curl command: %v", err)) + return 3 + } + c.Ui.Output(out) + return 0 + } + + // Re-implement a big chunk of api/api.go since we don't export it. + client := cleanhttp.DefaultClient() + transport := client.Transport.(*http.Transport) + transport.TLSHandshakeTimeout = 10 * time.Second + transport.TLSClientConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + } + + if err := api.ConfigureTLS(client, config.TLSConfig); err != nil { + c.Ui.Error(fmt.Sprintf("Error configuring TLS: %v", err)) + return 4 + } + + setQueryParams(config, path) + + verbose("> %s %s", c.method, path) + + req, err := http.NewRequest(c.method, path.String(), c.body) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error making request: %v", err)) + return 5 + } + + // Set headers from command line + req.Header = headerFlags.headers + + // Add token header if it doesn't already exist and is set + if req.Header.Get("X-Nomad-Token") == "" && config.SecretID != "" { + req.Header.Set("X-Nomad-Token", config.SecretID) + } + + // Configure HTTP basic authentication if set + if path.User != nil { + username := path.User.Username() + password, _ := path.User.Password() + req.SetBasicAuth(username, password) + } else if config.HttpAuth != nil { + req.SetBasicAuth(config.HttpAuth.Username, config.HttpAuth.Password) + } + + for k, vals := range req.Header { + for _, v := range vals { + verbose("> %s: %s", k, v) + } + } + + verbose("* Sending request and receiving response...") + + // Do the request! + resp, err := client.Do(req) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error performing request: %v", err)) + return 6 + } + defer resp.Body.Close() + + verbose("< %s %s", resp.Proto, resp.Status) + for k, vals := range resp.Header { + for _, v := range vals { + verbose("< %s: %s", k, v) + } + } + + n, err := io.Copy(os.Stdout, resp.Body) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error reading response after %d bytes: %v", n, err)) + return 7 + } + + if len(resp.Trailer) > 0 { + verbose("* Trailer Headers") + for k, vals := range resp.Trailer { + for _, v := range vals { + verbose("< %s: %s", k, v) + } + } + } + + return 0 +} + +// setQueryParams converts API configuration to query parameters. Updates path +// parameter in place. +func setQueryParams(config *api.Config, path *url.URL) { + queryParams := path.Query() + + // Prefer region explicitly set in path, otherwise fallback to config + // if one is set. + if queryParams.Get("region") == "" && config.Region != "" { + queryParams["region"] = []string{config.Region} + } + + // Prefer namespace explicitly set in path, otherwise fallback to + // config if one is set. + if queryParams.Get("namespace") == "" && config.Namespace != "" { + queryParams["namespace"] = []string{config.Namespace} + } + + // Re-encode query parameters + path.RawQuery = queryParams.Encode() +} + +// apiToCurl converts a Nomad HTTP API config and path to its corresponding +// curl command or returns an error. +func (c *CurlCommand) apiToCurl(config *api.Config, headers http.Header, path *url.URL) (string, error) { + parts := []string{"curl"} + + if c.verboseFlag { + parts = append(parts, "--verbose") + } + + if c.method != "" { + parts = append(parts, "-X "+c.method) + } + + if c.body != nil { + parts = append(parts, "--data-binary @-") + } + + if config.TLSConfig != nil { + parts = tlsToCurl(parts, config.TLSConfig) + + // If a TLS server name is set we must alter the URL and use + // curl's --connect-to flag. + if v := config.TLSConfig.TLSServerName; v != "" { + pathHost, port, err := net.SplitHostPort(path.Host) + if err != nil { + return "", fmt.Errorf("error determining port: %v", err) + } + + // curl uses the url for SNI so override it with the + // configured server name + path.Host = net.JoinHostPort(v, port) + + // curl uses --connect-to to allow specifying a + // different connection address for the hostname in the + // path. The format is: + // logical-host:logical-port:actual-host:actual-port + // Ports will always match since only the hostname is + // overridden for SNI. + parts = append(parts, fmt.Sprintf(`--connect-to "%s:%s:%s:%s"`, + v, port, pathHost, port)) + } + } + + // Add headers + for k, vals := range headers { + for _, v := range vals { + parts = append(parts, fmt.Sprintf(`-H '%s: %s'`, k, v)) + } + } + + // Only write NOMAD_TOKEN to stdout if it was specified via -token. + // Otherwise output a static string that references the ACL token + // environment variable. + if headers.Get("X-Nomad-Token") == "" { + if c.Meta.token != "" { + parts = append(parts, fmt.Sprintf(`-H "X-Nomad-Token: %s"`, c.Meta.token)) + } else if v := os.Getenv("NOMAD_TOKEN"); v != "" { + parts = append(parts, `-H "X-Nomad-Token: ${NOMAD_TOKEN}"`) + } + } + + // Never write http auth to stdout. Instead output a static string that + // references the HTTP auth environment variable. + if auth := os.Getenv("NOMAD_HTTP_AUTH"); auth != "" { + parts = append(parts, `-u "$NOMAD_HTTP_AUTH"`) + } + + setQueryParams(config, path) + + parts = append(parts, path.String()) + + return strings.Join(parts, " \\\n "), nil +} + +// tlsToCurl converts TLS configuration to their corresponding curl flags. +func tlsToCurl(parts []string, tlsConfig *api.TLSConfig) []string { + if v := tlsConfig.CACert; v != "" { + parts = append(parts, fmt.Sprintf(`--cacert "%s"`, v)) + } + + if v := tlsConfig.CAPath; v != "" { + parts = append(parts, fmt.Sprintf(`--capath "%s"`, v)) + } + + if v := tlsConfig.ClientCert; v != "" { + parts = append(parts, fmt.Sprintf(`--cert "%s"`, v)) + } + + if v := tlsConfig.ClientKey; v != "" { + parts = append(parts, fmt.Sprintf(`--key "%s"`, v)) + } + + // TLSServerName has already been configured as it may change the path. + + if tlsConfig.Insecure { + parts = append(parts, `--insecure`) + } + + return parts +} + +// pathToURL converts a curl path argumet to URL. Paths without a host are +// prefixed with $NOMAD_ADDR or http://127.0.0.1:4646. +func pathToURL(config *api.Config, path string) (*url.URL, error) { + // If the scheme is missing, add it + if !strings.HasPrefix(path, "http://") && !strings.HasPrefix(path, "https://") { + scheme := "http" + if config.TLSConfig != nil { + if config.TLSConfig.CACert != "" || + config.TLSConfig.CAPath != "" || + config.TLSConfig.ClientCert != "" || + config.TLSConfig.TLSServerName != "" || + config.TLSConfig.Insecure { + + // TLS configured, but scheme not set. Assume + // https. + scheme = "https" + } + } + + path = fmt.Sprintf("%s://%s", scheme, path) + } + + u, err := url.Parse(path) + if err != nil { + return nil, err + } + + // If URL.Scheme is empty, use defaults from client config + if u.Host == "" { + confURL, err := url.Parse(config.Address) + if err != nil { + return nil, fmt.Errorf("Unable to parse configured address: %v", err) + } + u.Host = confURL.Host + } + + return u, nil +} + +// headerFlags is a flag.Value implementation for collecting multiple -H flags. +type headerFlags struct { + headers http.Header +} + +func newHeaderFlags() *headerFlags { + return &headerFlags{ + headers: make(http.Header), + } +} + +func (*headerFlags) String() string { return "" } + +func (h *headerFlags) Set(v string) error { + parts := strings.SplitN(v, ":", 2) + if len(parts) != 2 { + return fmt.Errorf("Headers must be in the form 'Key: Value' but found: %q", v) + } + + h.headers.Add(parts[0], parts[1]) + return nil +} diff --git a/website/content/docs/commands/curl.mdx b/website/content/docs/commands/curl.mdx new file mode 100644 index 000000000..fa5be8b01 --- /dev/null +++ b/website/content/docs/commands/curl.mdx @@ -0,0 +1,54 @@ +--- +layout: docs +page_title: 'Commands: curl' +description: | + curl is a utility command for accessing Nomad's HTTP API similar to the + popular open source program of the same name. +--- + +# Command: curl + +The curl command allows easy access to Nomad's HTTP API similar to the popular +[curl] program. Nomad's curl command reads [environment variables][envvars] to +dramatically ease HTTP API access compared to trying to manually write the same +command with the third party `curl` command. + +For example for the following environment: + +``` +NOMAD_TOKEN=d4434353-c797-19e4-a14d-4068241f86a4 +NOMAD_CACERT=$HOME/.nomad/ca.pem +NOMAD_CLIENT_CERT=$HOME/.nomad/cli.pem +NOMAD_CLIENT_KEY=$HOME/.nomad/client-key.pem +NOMAD_TLS_SERVER_NAME=client.global.nomad +NOMAD_ADDR=https://remote.client123.internal:4646 +``` + +Accessing Nomad's [`/v1/metrics`][metrics] HTTP endpoint with `nomad curl` +would require: + +``` +nomad curl /v1/metrics +``` + +Performing the same request using the external `curl` tool would require: + +``` +curl \ + --cacert "$HOME/.nomad/ca.pem" \ + --cert "$HOME/.nomad/client.pem" \ + --key "$HOME/.nomad/client-key.pem" \ + --connect-to "client.global.nomad:4646:remote.client123.internal:4646" \ + -H "X-Nomad-Token: ${NOMAD_TOKEN}" \ + https://client.global.nomad:4646/v1/metrics +``` + +The `-dryrun` flag for `nomad curl` will output a curl command instead of +performing the HTTP request immediately. Note that you do *not* need the 3rd +party `curl` command installed to use `nomad curl`. The `curl` output from +`-dryrun` is intended for use in scripts or running in locations without a +Nomad binary present. + +[curl]: https://curl.se/ +[envvars]: /docs/commands#environment-variables +[metrics]: /api-docs/metrics#metrics-http-api diff --git a/website/data/docs-nav-data.json b/website/data/docs-nav-data.json index 7a6b09ea8..313afb421 100644 --- a/website/data/docs-nav-data.json +++ b/website/data/docs-nav-data.json @@ -328,6 +328,10 @@ } ] }, + { + "title": "curl", + "path": "commands/curl" + }, { "title": "deployment", "routes": [