package api import ( "bytes" "context" "crypto/tls" "encoding/json" "fmt" "io" "log" "net" "net/http" "net/url" "os" "strconv" "strings" "time" "github.com/hashicorp/go-cleanhttp" "github.com/hashicorp/go-rootcerts" ) const ( // HTTPAddrEnvName defines an environment variable name which sets // the HTTP address if there is no -http-addr specified. HTTPAddrEnvName = "CONSUL_HTTP_ADDR" // HTTPTokenEnvName defines an environment variable name which sets // the HTTP token. HTTPTokenEnvName = "CONSUL_HTTP_TOKEN" // HTTPAuthEnvName defines an environment variable name which sets // the HTTP authentication header. HTTPAuthEnvName = "CONSUL_HTTP_AUTH" // HTTPSSLEnvName defines an environment variable name which sets // whether or not to use HTTPS. HTTPSSLEnvName = "CONSUL_HTTP_SSL" // HTTPCAFile defines an environment variable name which sets the // CA file to use for talking to Consul over TLS. HTTPCAFile = "CONSUL_CACERT" // HTTPCAPath defines an environment variable name which sets the // path to a directory of CA certs to use for talking to Consul over TLS. HTTPCAPath = "CONSUL_CAPATH" // HTTPClientCert defines an environment variable name which sets the // client cert file to use for talking to Consul over TLS. HTTPClientCert = "CONSUL_CLIENT_CERT" // HTTPClientKey defines an environment variable name which sets the // client key file to use for talking to Consul over TLS. HTTPClientKey = "CONSUL_CLIENT_KEY" // HTTPTLSServerName defines an environment variable name which sets the // server name to use as the SNI host when connecting via TLS HTTPTLSServerName = "CONSUL_TLS_SERVER_NAME" // HTTPSSLVerifyEnvName defines an environment variable name which sets // whether or not to disable certificate checking. HTTPSSLVerifyEnvName = "CONSUL_HTTP_SSL_VERIFY" ) // QueryOptions are used to parameterize a query type QueryOptions struct { // Providing a datacenter overwrites the DC provided // by the Config Datacenter string // AllowStale allows any Consul server (non-leader) to service // a read. This allows for lower latency and higher throughput AllowStale bool // RequireConsistent forces the read to be fully consistent. // This is more expensive but prevents ever performing a stale // read. RequireConsistent bool // WaitIndex is used to enable a blocking query. Waits // until the timeout or the next index is reached WaitIndex uint64 // WaitTime is used to bound the duration of a wait. // Defaults to that of the Config, but can be overridden. WaitTime time.Duration // Token is used to provide a per-request ACL token // which overrides the agent's default token. Token string // Near is used to provide a node name that will sort the results // in ascending order based on the estimated round trip time from // that node. Setting this to "_agent" will use the agent's node // for the sort. Near string // NodeMeta is used to filter results by nodes with the given // metadata key/value pairs. Currently, only one key/value pair can // be provided for filtering. NodeMeta map[string]string // RelayFactor is used in keyring operations to cause reponses to be // relayed back to the sender through N other random nodes. Must be // a value from 0 to 5 (inclusive). RelayFactor uint8 } // WriteOptions are used to parameterize a write type WriteOptions struct { // Providing a datacenter overwrites the DC provided // by the Config Datacenter string // Token is used to provide a per-request ACL token // which overrides the agent's default token. Token string // RelayFactor is used in keyring operations to cause reponses to be // relayed back to the sender through N other random nodes. Must be // a value from 0 to 5 (inclusive). RelayFactor uint8 } // QueryMeta is used to return meta data about a query type QueryMeta struct { // LastIndex. This can be used as a WaitIndex to perform // a blocking query LastIndex uint64 // Time of last contact from the leader for the // server servicing the request LastContact time.Duration // Is there a known leader KnownLeader bool // How long did the request take RequestTime time.Duration // Is address translation enabled for HTTP responses on this agent AddressTranslationEnabled bool } // WriteMeta is used to return meta data about a write type WriteMeta struct { // How long did the request take RequestTime time.Duration } // HttpBasicAuth is used to authenticate http client with HTTP Basic Authentication type HttpBasicAuth struct { // Username to use for HTTP Basic Authentication Username string // Password to use for HTTP Basic Authentication Password string } // Config is used to configure the creation of a client type Config struct { // Address is the address of the Consul server Address string // Scheme is the URI scheme for the Consul server Scheme string // Datacenter to use. If not provided, the default agent datacenter is used. Datacenter string // HttpClient is the client to use. Default will be // used if not provided. HttpClient *http.Client // HttpAuth is the auth info to use for http access. HttpAuth *HttpBasicAuth // WaitTime limits how long a Watch will block. If not provided, // the agent default values will be used. WaitTime time.Duration // Token is used to provide a per-request ACL token // which overrides the agent's default token. Token string TLSConfig TLSConfig } // TLSConfig is used to generate a TLSClientConfig that's useful for talking to // Consul using TLS. type TLSConfig struct { // Address is the optional address of the Consul server. The port, if any // will be removed from here and this will be set to the ServerName of the // resulting config. Address string // CAFile is the optional path to the CA certificate used for Consul // communication, defaults to the system bundle if not specified. CAFile string // CAPath is the optional path to a directory of CA certificates to use for // Consul communication, defaults to the system bundle if not specified. CAPath string // CertFile is the optional path to the certificate for Consul // communication. If this is set then you need to also set KeyFile. CertFile string // KeyFile is the optional path to the private key for Consul communication. // If this is set then you need to also set CertFile. KeyFile string // InsecureSkipVerify if set to true will disable TLS host verification. InsecureSkipVerify bool } // DefaultConfig returns a default configuration for the client. By default this // will pool and reuse idle connections to Consul. If you have a long-lived // client object, this is the desired behavior and should make the most efficient // use of the connections to Consul. If you don't reuse a client object , which // is not recommended, then you may notice idle connections building up over // time. To avoid this, use the DefaultNonPooledConfig() instead. func DefaultConfig() *Config { return defaultConfig(cleanhttp.DefaultPooledTransport) } // DefaultNonPooledConfig returns a default configuration for the client which // does not pool connections. This isn't a recommended configuration because it // will reconnect to Consul on every request, but this is useful to avoid the // accumulation of idle connections if you make many client objects during the // lifetime of your application. func DefaultNonPooledConfig() *Config { return defaultConfig(cleanhttp.DefaultTransport) } // defaultConfig returns the default configuration for the client, using the // given function to make the transport. func defaultConfig(transportFn func() *http.Transport) *Config { config := &Config{ Address: "127.0.0.1:8500", Scheme: "http", HttpClient: &http.Client{ Transport: transportFn(), }, } if addr := os.Getenv(HTTPAddrEnvName); addr != "" { config.Address = addr } if token := os.Getenv(HTTPTokenEnvName); token != "" { config.Token = token } if auth := os.Getenv(HTTPAuthEnvName); auth != "" { var username, password string if strings.Contains(auth, ":") { split := strings.SplitN(auth, ":", 2) username = split[0] password = split[1] } else { username = auth } config.HttpAuth = &HttpBasicAuth{ Username: username, Password: password, } } if ssl := os.Getenv(HTTPSSLEnvName); ssl != "" { enabled, err := strconv.ParseBool(ssl) if err != nil { log.Printf("[WARN] client: could not parse %s: %s", HTTPSSLEnvName, err) } if enabled { config.Scheme = "https" } } if v := os.Getenv(HTTPTLSServerName); v != "" { config.TLSConfig.Address = v } if v := os.Getenv(HTTPCAFile); v != "" { config.TLSConfig.CAFile = v } if v := os.Getenv(HTTPCAPath); v != "" { config.TLSConfig.CAPath = v } if v := os.Getenv(HTTPClientCert); v != "" { config.TLSConfig.CertFile = v } if v := os.Getenv(HTTPClientKey); v != "" { config.TLSConfig.KeyFile = v } if v := os.Getenv(HTTPSSLVerifyEnvName); v != "" { doVerify, err := strconv.ParseBool(v) if err != nil { log.Printf("[WARN] client: could not parse %s: %s", HTTPSSLVerifyEnvName, err) } if !doVerify { config.TLSConfig.InsecureSkipVerify = true } } return config } // TLSConfig is used to generate a TLSClientConfig that's useful for talking to // Consul using TLS. func SetupTLSConfig(tlsConfig *TLSConfig) (*tls.Config, error) { tlsClientConfig := &tls.Config{ InsecureSkipVerify: tlsConfig.InsecureSkipVerify, } if tlsConfig.Address != "" { server := tlsConfig.Address hasPort := strings.LastIndex(server, ":") > strings.LastIndex(server, "]") if hasPort { var err error server, _, err = net.SplitHostPort(server) if err != nil { return nil, err } } tlsClientConfig.ServerName = server } if tlsConfig.CertFile != "" && tlsConfig.KeyFile != "" { tlsCert, err := tls.LoadX509KeyPair(tlsConfig.CertFile, tlsConfig.KeyFile) if err != nil { return nil, err } tlsClientConfig.Certificates = []tls.Certificate{tlsCert} } rootConfig := &rootcerts.Config{ CAFile: tlsConfig.CAFile, CAPath: tlsConfig.CAPath, } if err := rootcerts.ConfigureTLS(tlsClientConfig, rootConfig); err != nil { return nil, err } return tlsClientConfig, nil } // Client provides a client to the Consul API type Client struct { config Config } // NewClient returns a new client func NewClient(config *Config) (*Client, error) { // bootstrap the config defConfig := DefaultConfig() if len(config.Address) == 0 { config.Address = defConfig.Address } if len(config.Scheme) == 0 { config.Scheme = defConfig.Scheme } if config.HttpClient == nil { config.HttpClient = defConfig.HttpClient } if config.TLSConfig.Address == "" { config.TLSConfig.Address = defConfig.TLSConfig.Address } if config.TLSConfig.CAFile == "" { config.TLSConfig.CAFile = defConfig.TLSConfig.CAFile } if config.TLSConfig.CAPath == "" { config.TLSConfig.CAPath = defConfig.TLSConfig.CAPath } if config.TLSConfig.CertFile == "" { config.TLSConfig.CertFile = defConfig.TLSConfig.CertFile } if config.TLSConfig.KeyFile == "" { config.TLSConfig.KeyFile = defConfig.TLSConfig.KeyFile } if !config.TLSConfig.InsecureSkipVerify { config.TLSConfig.InsecureSkipVerify = defConfig.TLSConfig.InsecureSkipVerify } tlsClientConfig, err := SetupTLSConfig(&config.TLSConfig) // We don't expect this to fail given that we aren't // parsing any of the input, but we panic just in case // since this doesn't have an error return. if err != nil { return nil, err } config.HttpClient.Transport.(*http.Transport).TLSClientConfig = tlsClientConfig parts := strings.SplitN(config.Address, "://", 2) if len(parts) == 2 { switch parts[0] { case "http": case "https": config.Scheme = "https" case "unix": trans := cleanhttp.DefaultTransport() trans.DialContext = func(_ context.Context, _, _ string) (net.Conn, error) { return net.Dial("unix", parts[1]) } config.HttpClient = &http.Client{ Transport: trans, } default: return nil, fmt.Errorf("Unknown protocol scheme: %s", parts[0]) } config.Address = parts[1] } client := &Client{ config: *config, } return client, nil } // request is used to help build up a request type request struct { config *Config method string url *url.URL params url.Values body io.Reader header http.Header obj interface{} } // setQueryOptions is used to annotate the request with // additional query options func (r *request) setQueryOptions(q *QueryOptions) { if q == nil { return } if q.Datacenter != "" { r.params.Set("dc", q.Datacenter) } if q.AllowStale { r.params.Set("stale", "") } if q.RequireConsistent { r.params.Set("consistent", "") } if q.WaitIndex != 0 { r.params.Set("index", strconv.FormatUint(q.WaitIndex, 10)) } if q.WaitTime != 0 { r.params.Set("wait", durToMsec(q.WaitTime)) } if q.Token != "" { r.header.Set("X-Consul-Token", q.Token) } if q.Near != "" { r.params.Set("near", q.Near) } if len(q.NodeMeta) > 0 { for key, value := range q.NodeMeta { r.params.Add("node-meta", key+":"+value) } } if q.RelayFactor != 0 { r.params.Set("relay-factor", strconv.Itoa(int(q.RelayFactor))) } } // durToMsec converts a duration to a millisecond specified string. If the // user selected a positive value that rounds to 0 ms, then we will use 1 ms // so they get a short delay, otherwise Consul will translate the 0 ms into // a huge default delay. func durToMsec(dur time.Duration) string { ms := dur / time.Millisecond if dur > 0 && ms == 0 { ms = 1 } return fmt.Sprintf("%dms", ms) } // serverError is a string we look for to detect 500 errors. const serverError = "Unexpected response code: 500" // IsServerError returns true for 500 errors from the Consul servers, these are // usually retryable at a later time. func IsServerError(err error) bool { if err == nil { return false } // TODO (slackpad) - Make a real error type here instead of using // a string check. return strings.Contains(err.Error(), serverError) } // setWriteOptions is used to annotate the request with // additional write options func (r *request) setWriteOptions(q *WriteOptions) { if q == nil { return } if q.Datacenter != "" { r.params.Set("dc", q.Datacenter) } if q.Token != "" { r.header.Set("X-Consul-Token", q.Token) } if q.RelayFactor != 0 { r.params.Set("relay-factor", strconv.Itoa(int(q.RelayFactor))) } } // toHTTP converts the request to an HTTP request func (r *request) toHTTP() (*http.Request, error) { // Encode the query parameters r.url.RawQuery = r.params.Encode() // Check if we should encode the body if r.body == nil && r.obj != nil { if b, err := encodeBody(r.obj); err != nil { return nil, err } else { r.body = b } } // Create the HTTP request req, err := http.NewRequest(r.method, r.url.RequestURI(), r.body) if err != nil { return nil, err } req.URL.Host = r.url.Host req.URL.Scheme = r.url.Scheme req.Host = r.url.Host req.Header = r.header // Setup auth if r.config.HttpAuth != nil { req.SetBasicAuth(r.config.HttpAuth.Username, r.config.HttpAuth.Password) } return req, nil } // newRequest is used to create a new request func (c *Client) newRequest(method, path string) *request { r := &request{ config: &c.config, method: method, url: &url.URL{ Scheme: c.config.Scheme, Host: c.config.Address, Path: path, }, params: make(map[string][]string), header: make(http.Header), } if c.config.Datacenter != "" { r.params.Set("dc", c.config.Datacenter) } if c.config.WaitTime != 0 { r.params.Set("wait", durToMsec(r.config.WaitTime)) } if c.config.Token != "" { r.header.Set("X-Consul-Token", r.config.Token) } return r } // doRequest runs a request with our client func (c *Client) doRequest(r *request) (time.Duration, *http.Response, error) { req, err := r.toHTTP() if err != nil { return 0, nil, err } start := time.Now() resp, err := c.config.HttpClient.Do(req) diff := time.Now().Sub(start) return diff, resp, err } // Query is used to do a GET request against an endpoint // and deserialize the response into an interface using // standard Consul conventions. func (c *Client) query(endpoint string, out interface{}, q *QueryOptions) (*QueryMeta, error) { r := c.newRequest("GET", endpoint) r.setQueryOptions(q) rtt, resp, err := requireOK(c.doRequest(r)) if err != nil { return nil, err } defer resp.Body.Close() qm := &QueryMeta{} parseQueryMeta(resp, qm) qm.RequestTime = rtt if err := decodeBody(resp, out); err != nil { return nil, err } return qm, nil } // write is used to do a PUT request against an endpoint // and serialize/deserialized using the standard Consul conventions. func (c *Client) write(endpoint string, in, out interface{}, q *WriteOptions) (*WriteMeta, error) { r := c.newRequest("PUT", endpoint) r.setWriteOptions(q) r.obj = in rtt, resp, err := requireOK(c.doRequest(r)) if err != nil { return nil, err } defer resp.Body.Close() wm := &WriteMeta{RequestTime: rtt} if out != nil { if err := decodeBody(resp, &out); err != nil { return nil, err } } return wm, nil } // parseQueryMeta is used to help parse query meta-data func parseQueryMeta(resp *http.Response, q *QueryMeta) error { header := resp.Header // Parse the X-Consul-Index index, err := strconv.ParseUint(header.Get("X-Consul-Index"), 10, 64) if err != nil { return fmt.Errorf("Failed to parse X-Consul-Index: %v", err) } q.LastIndex = index // Parse the X-Consul-LastContact last, err := strconv.ParseUint(header.Get("X-Consul-LastContact"), 10, 64) if err != nil { return fmt.Errorf("Failed to parse X-Consul-LastContact: %v", err) } q.LastContact = time.Duration(last) * time.Millisecond // Parse the X-Consul-KnownLeader switch header.Get("X-Consul-KnownLeader") { case "true": q.KnownLeader = true default: q.KnownLeader = false } // Parse X-Consul-Translate-Addresses switch header.Get("X-Consul-Translate-Addresses") { case "true": q.AddressTranslationEnabled = true default: q.AddressTranslationEnabled = false } return nil } // decodeBody is used to JSON decode a body func decodeBody(resp *http.Response, out interface{}) error { dec := json.NewDecoder(resp.Body) return dec.Decode(out) } // encodeBody is used to encode a request body func encodeBody(obj interface{}) (io.Reader, error) { buf := bytes.NewBuffer(nil) enc := json.NewEncoder(buf) if err := enc.Encode(obj); err != nil { return nil, err } return buf, nil } // requireOK is used to wrap doRequest and check for a 200 func requireOK(d time.Duration, resp *http.Response, e error) (time.Duration, *http.Response, error) { if e != nil { if resp != nil { resp.Body.Close() } return d, nil, e } if resp.StatusCode != 200 { var buf bytes.Buffer io.Copy(&buf, resp.Body) resp.Body.Close() return d, nil, fmt.Errorf("Unexpected response code: %d (%s)", resp.StatusCode, buf.Bytes()) } return d, resp, nil }