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