// Package disco handles Terraform's remote service discovery protocol. // // This protocol allows mapping from a service hostname, as produced by the // svchost package, to a set of services supported by that host and the // endpoint information for each supported service. package disco import ( "encoding/json" "errors" "fmt" "io" "io/ioutil" "log" "mime" "net/http" "net/url" "time" cleanhttp "github.com/hashicorp/go-cleanhttp" "github.com/hashicorp/terraform/httpclient" "github.com/hashicorp/terraform/svchost" "github.com/hashicorp/terraform/svchost/auth" ) const ( // Fixed path to the discovery manifest. discoPath = "/.well-known/terraform.json" // Arbitrary-but-small number to prevent runaway redirect loops. maxRedirects = 3 // Arbitrary-but-small time limit to prevent UI "hangs" during discovery. discoTimeout = 11 * time.Second // 1MB - to prevent abusive services from using loads of our memory. maxDiscoDocBytes = 1 * 1024 * 1024 ) // httpTransport is overridden during tests, to skip TLS verification. var httpTransport = cleanhttp.DefaultPooledTransport() // Disco is the main type in this package, which allows discovery on given // hostnames and caches the results by hostname to avoid repeated requests // for the same information. type Disco struct { hostCache map[svchost.Hostname]*Host credsSrc auth.CredentialsSource // Transport is a custom http.RoundTripper to use. Transport http.RoundTripper } // New returns a new initialized discovery object. func New() *Disco { return NewWithCredentialsSource(nil) } // NewWithCredentialsSource returns a new discovery object initialized with // the given credentials source. func NewWithCredentialsSource(credsSrc auth.CredentialsSource) *Disco { return &Disco{ hostCache: make(map[svchost.Hostname]*Host), credsSrc: credsSrc, Transport: httpTransport, } } // SetCredentialsSource provides a credentials source that will be used to // add credentials to outgoing discovery requests, where available. // // If this method is never called, no outgoing discovery requests will have // credentials. func (d *Disco) SetCredentialsSource(src auth.CredentialsSource) { d.credsSrc = src } // CredentialsForHost returns a non-nil HostCredentials if the embedded source has // credentials available for the host, and a nil HostCredentials if it does not. func (d *Disco) CredentialsForHost(hostname svchost.Hostname) (auth.HostCredentials, error) { if d.credsSrc == nil { return nil, nil } return d.credsSrc.ForHost(hostname) } // ForceHostServices provides a pre-defined set of services for a given // host, which prevents the receiver from attempting network-based discovery // for the given host. Instead, the given services map will be returned // verbatim. // // When providing "forced" services, any relative URLs are resolved against // the initial discovery URL that would have been used for network-based // discovery, yielding the same results as if the given map were published // at the host's default discovery URL, though using absolute URLs is strongly // recommended to make the configured behavior more explicit. func (d *Disco) ForceHostServices(hostname svchost.Hostname, services map[string]interface{}) { if services == nil { services = map[string]interface{}{} } d.hostCache[hostname] = &Host{ discoURL: &url.URL{ Scheme: "https", Host: string(hostname), Path: discoPath, }, hostname: hostname.ForDisplay(), services: services, transport: d.Transport, } } // Discover runs the discovery protocol against the given hostname (which must // already have been validated and prepared with svchost.ForComparison) and // returns an object describing the services available at that host. // // If a given hostname supports no Terraform services at all, a non-nil but // empty Host object is returned. When giving feedback to the end user about // such situations, we say "host does not provide a service", // regardless of whether that is due to that service specifically being absent // or due to the host not providing Terraform services at all, since we don't // wish to expose the detail of whole-host discovery to an end-user. func (d *Disco) Discover(hostname svchost.Hostname) (*Host, error) { if host, cached := d.hostCache[hostname]; cached { return host, nil } host, err := d.discover(hostname) if err != nil { return nil, err } d.hostCache[hostname] = host return host, nil } // DiscoverServiceURL is a convenience wrapper for discovery on a given // hostname and then looking up a particular service in the result. func (d *Disco) DiscoverServiceURL(hostname svchost.Hostname, serviceID string) (*url.URL, error) { host, err := d.Discover(hostname) if err != nil { return nil, err } return host.ServiceURL(serviceID) } // discover implements the actual discovery process, with its result cached // by the public-facing Discover method. func (d *Disco) discover(hostname svchost.Hostname) (*Host, error) { discoURL := &url.URL{ Scheme: "https", Host: hostname.String(), Path: discoPath, } client := &http.Client{ Transport: d.Transport, Timeout: discoTimeout, CheckRedirect: func(req *http.Request, via []*http.Request) error { log.Printf("[DEBUG] Service discovery redirected to %s", req.URL) if len(via) > maxRedirects { return errors.New("too many redirects") // this error will never actually be seen } return nil }, } req := &http.Request{ Header: make(http.Header), Method: "GET", URL: discoURL, } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", httpclient.UserAgentString()) creds, err := d.CredentialsForHost(hostname) if err != nil { log.Printf("[WARN] Failed to get credentials for %s: %s (ignoring)", hostname, err) } if creds != nil { // Update the request to include credentials. creds.PrepareRequest(req) } log.Printf("[DEBUG] Service discovery for %s at %s", hostname, discoURL) resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("Failed to request discovery document: %v", err) } defer resp.Body.Close() host := &Host{ // Use the discovery URL from resp.Request in // case the client followed any redirects. discoURL: resp.Request.URL, hostname: hostname.ForDisplay(), transport: d.Transport, } // Return the host without any services. if resp.StatusCode == 404 { return host, nil } if resp.StatusCode != 200 { return nil, fmt.Errorf("Failed to request discovery document: %s", resp.Status) } contentType := resp.Header.Get("Content-Type") mediaType, _, err := mime.ParseMediaType(contentType) if err != nil { return nil, fmt.Errorf("Discovery URL has a malformed Content-Type %q", contentType) } if mediaType != "application/json" { return nil, fmt.Errorf("Discovery URL returned an unsupported Content-Type %q", mediaType) } // This doesn't catch chunked encoding, because ContentLength is -1 in that case. if resp.ContentLength > maxDiscoDocBytes { // Size limit here is not a contractual requirement and so we may // adjust it over time if we find a different limit is warranted. return nil, fmt.Errorf( "Discovery doc response is too large (got %d bytes; limit %d)", resp.ContentLength, maxDiscoDocBytes, ) } // If the response is using chunked encoding then we can't predict its // size, but we'll at least prevent reading the entire thing into memory. lr := io.LimitReader(resp.Body, maxDiscoDocBytes) servicesBytes, err := ioutil.ReadAll(lr) if err != nil { return nil, fmt.Errorf("Error reading discovery document body: %v", err) } var services map[string]interface{} err = json.Unmarshal(servicesBytes, &services) if err != nil { return nil, fmt.Errorf("Failed to decode discovery document as a JSON object: %v", err) } host.services = services return host, nil } // Forget invalidates any cached record of the given hostname. If the host // has no cache entry then this is a no-op. func (d *Disco) Forget(hostname svchost.Hostname) { delete(d.hostCache, hostname) } // ForgetAll is like Forget, but for all of the hostnames that have cache entries. func (d *Disco) ForgetAll() { d.hostCache = make(map[svchost.Hostname]*Host) }