github.com/handlerbot/terraform@v0.10.0-beta1.0.20180726153736-26b68d98f9cb/svchost/disco/disco.go (about)

     1  // Package disco handles Terraform's remote service discovery protocol.
     2  //
     3  // This protocol allows mapping from a service hostname, as produced by the
     4  // svchost package, to a set of services supported by that host and the
     5  // endpoint information for each supported service.
     6  package disco
     7  
     8  import (
     9  	"encoding/json"
    10  	"errors"
    11  	"io"
    12  	"io/ioutil"
    13  	"log"
    14  	"mime"
    15  	"net/http"
    16  	"net/url"
    17  	"time"
    18  
    19  	cleanhttp "github.com/hashicorp/go-cleanhttp"
    20  	"github.com/hashicorp/terraform/svchost"
    21  	"github.com/hashicorp/terraform/svchost/auth"
    22  )
    23  
    24  const (
    25  	discoPath        = "/.well-known/terraform.json"
    26  	maxRedirects     = 3                // arbitrary-but-small number to prevent runaway redirect loops
    27  	discoTimeout     = 11 * time.Second // arbitrary-but-small time limit to prevent UI "hangs" during discovery
    28  	maxDiscoDocBytes = 1 * 1024 * 1024  // 1MB - to prevent abusive services from using loads of our memory
    29  )
    30  
    31  var httpTransport = cleanhttp.DefaultPooledTransport() // overridden during tests, to skip TLS verification
    32  
    33  // Disco is the main type in this package, which allows discovery on given
    34  // hostnames and caches the results by hostname to avoid repeated requests
    35  // for the same information.
    36  type Disco struct {
    37  	hostCache map[svchost.Hostname]Host
    38  	credsSrc  auth.CredentialsSource
    39  
    40  	// Transport is a custom http.RoundTripper to use.
    41  	// A package default is used if this is nil.
    42  	Transport http.RoundTripper
    43  }
    44  
    45  // NewDisco returns a new initialized Disco object.
    46  func NewDisco() *Disco {
    47  	return &Disco{}
    48  }
    49  
    50  // SetCredentialsSource provides a credentials source that will be used to
    51  // add credentials to outgoing discovery requests, where available.
    52  //
    53  // If this method is never called, no outgoing discovery requests will have
    54  // credentials.
    55  func (d *Disco) SetCredentialsSource(src auth.CredentialsSource) {
    56  	d.credsSrc = src
    57  }
    58  
    59  // ForceHostServices provides a pre-defined set of services for a given
    60  // host, which prevents the receiver from attempting network-based discovery
    61  // for the given host. Instead, the given services map will be returned
    62  // verbatim.
    63  //
    64  // When providing "forced" services, any relative URLs are resolved against
    65  // the initial discovery URL that would have been used for network-based
    66  // discovery, yielding the same results as if the given map were published
    67  // at the host's default discovery URL, though using absolute URLs is strongly
    68  // recommended to make the configured behavior more explicit.
    69  func (d *Disco) ForceHostServices(host svchost.Hostname, services map[string]interface{}) {
    70  	if d.hostCache == nil {
    71  		d.hostCache = map[svchost.Hostname]Host{}
    72  	}
    73  	if services == nil {
    74  		services = map[string]interface{}{}
    75  	}
    76  	d.hostCache[host] = Host{
    77  		discoURL: &url.URL{
    78  			Scheme: "https",
    79  			Host:   string(host),
    80  			Path:   discoPath,
    81  		},
    82  		services: services,
    83  	}
    84  }
    85  
    86  // Discover runs the discovery protocol against the given hostname (which must
    87  // already have been validated and prepared with svchost.ForComparison) and
    88  // returns an object describing the services available at that host.
    89  //
    90  // If a given hostname supports no Terraform services at all, a non-nil but
    91  // empty Host object is returned. When giving feedback to the end user about
    92  // such situations, we say e.g. "the host <name> doesn't provide a module
    93  // registry", regardless of whether that is due to that service specifically
    94  // being absent or due to the host not providing Terraform services at all,
    95  // since we don't wish to expose the detail of whole-host discovery to an
    96  // end-user.
    97  func (d *Disco) Discover(host svchost.Hostname) Host {
    98  	if d.hostCache == nil {
    99  		d.hostCache = map[svchost.Hostname]Host{}
   100  	}
   101  	if cache, cached := d.hostCache[host]; cached {
   102  		return cache
   103  	}
   104  
   105  	ret := d.discover(host)
   106  	d.hostCache[host] = ret
   107  	return ret
   108  }
   109  
   110  // DiscoverServiceURL is a convenience wrapper for discovery on a given
   111  // hostname and then looking up a particular service in the result.
   112  func (d *Disco) DiscoverServiceURL(host svchost.Hostname, serviceID string) *url.URL {
   113  	return d.Discover(host).ServiceURL(serviceID)
   114  }
   115  
   116  // discover implements the actual discovery process, with its result cached
   117  // by the public-facing Discover method.
   118  func (d *Disco) discover(host svchost.Hostname) Host {
   119  	discoURL := &url.URL{
   120  		Scheme: "https",
   121  		Host:   host.String(),
   122  		Path:   discoPath,
   123  	}
   124  
   125  	t := d.Transport
   126  	if t == nil {
   127  		t = httpTransport
   128  	}
   129  
   130  	client := &http.Client{
   131  		Transport: t,
   132  		Timeout:   discoTimeout,
   133  
   134  		CheckRedirect: func(req *http.Request, via []*http.Request) error {
   135  			log.Printf("[DEBUG] Service discovery redirected to %s", req.URL)
   136  			if len(via) > maxRedirects {
   137  				return errors.New("too many redirects") // (this error message will never actually be seen)
   138  			}
   139  			return nil
   140  		},
   141  	}
   142  
   143  	req := &http.Request{
   144  		Method: "GET",
   145  		URL:    discoURL,
   146  	}
   147  
   148  	if d.credsSrc != nil {
   149  		creds, err := d.credsSrc.ForHost(host)
   150  		if err == nil {
   151  			if creds != nil {
   152  				creds.PrepareRequest(req) // alters req to include credentials
   153  			}
   154  		} else {
   155  			log.Printf("[WARN] Failed to get credentials for %s: %s (ignoring)", host, err)
   156  		}
   157  	}
   158  
   159  	log.Printf("[DEBUG] Service discovery for %s at %s", host, discoURL)
   160  
   161  	ret := Host{
   162  		discoURL: discoURL,
   163  	}
   164  
   165  	resp, err := client.Do(req)
   166  	if err != nil {
   167  		log.Printf("[WARN] Failed to request discovery document: %s", err)
   168  		return ret // empty
   169  	}
   170  	defer resp.Body.Close()
   171  
   172  	if resp.StatusCode != 200 {
   173  		log.Printf("[WARN] Failed to request discovery document: %s", resp.Status)
   174  		return ret // empty
   175  	}
   176  
   177  	// If the client followed any redirects, we will have a new URL to use
   178  	// as our base for relative resolution.
   179  	ret.discoURL = resp.Request.URL
   180  
   181  	contentType := resp.Header.Get("Content-Type")
   182  	mediaType, _, err := mime.ParseMediaType(contentType)
   183  	if err != nil {
   184  		log.Printf("[WARN] Discovery URL has malformed Content-Type %q", contentType)
   185  		return ret // empty
   186  	}
   187  	if mediaType != "application/json" {
   188  		log.Printf("[DEBUG] Discovery URL returned Content-Type %q, rather than application/json", mediaType)
   189  		return ret // empty
   190  	}
   191  
   192  	// (this doesn't catch chunked encoding, because ContentLength is -1 in that case...)
   193  	if resp.ContentLength > maxDiscoDocBytes {
   194  		// Size limit here is not a contractual requirement and so we may
   195  		// adjust it over time if we find a different limit is warranted.
   196  		log.Printf("[WARN] Discovery doc response is too large (got %d bytes; limit %d)", resp.ContentLength, maxDiscoDocBytes)
   197  		return ret // empty
   198  	}
   199  
   200  	// If the response is using chunked encoding then we can't predict
   201  	// its size, but we'll at least prevent reading the entire thing into
   202  	// memory.
   203  	lr := io.LimitReader(resp.Body, maxDiscoDocBytes)
   204  
   205  	servicesBytes, err := ioutil.ReadAll(lr)
   206  	if err != nil {
   207  		log.Printf("[WARN] Error reading discovery document body: %s", err)
   208  		return ret // empty
   209  	}
   210  
   211  	var services map[string]interface{}
   212  	err = json.Unmarshal(servicesBytes, &services)
   213  	if err != nil {
   214  		log.Printf("[WARN] Failed to decode discovery document as a JSON object: %s", err)
   215  		return ret // empty
   216  	}
   217  
   218  	ret.services = services
   219  	return ret
   220  }
   221  
   222  // Forget invalidates any cached record of the given hostname. If the host
   223  // has no cache entry then this is a no-op.
   224  func (d *Disco) Forget(host svchost.Hostname) {
   225  	delete(d.hostCache, host)
   226  }
   227  
   228  // ForgetAll is like Forget, but for all of the hostnames that have cache entries.
   229  func (d *Disco) ForgetAll() {
   230  	d.hostCache = nil
   231  }