| package getproviders |
| |
| import ( |
| "context" |
| "encoding/json" |
| "fmt" |
| "io" |
| "log" |
| "mime" |
| "net/http" |
| "net/url" |
| "path" |
| "strings" |
| |
| "github.com/hashicorp/go-retryablehttp" |
| svchost "github.com/hashicorp/terraform-svchost" |
| svcauth "github.com/hashicorp/terraform-svchost/auth" |
| "golang.org/x/net/idna" |
| |
| "github.com/hashicorp/terraform/internal/addrs" |
| "github.com/hashicorp/terraform/internal/httpclient" |
| "github.com/hashicorp/terraform/internal/logging" |
| "github.com/hashicorp/terraform/version" |
| ) |
| |
| // HTTPMirrorSource is a source that reads provider metadata from a provider |
| // mirror that is accessible over the HTTP provider mirror protocol. |
| type HTTPMirrorSource struct { |
| baseURL *url.URL |
| creds svcauth.CredentialsSource |
| httpClient *retryablehttp.Client |
| } |
| |
| var _ Source = (*HTTPMirrorSource)(nil) |
| |
| // NewHTTPMirrorSource constructs and returns a new network mirror source with |
| // the given base URL. The relative URL offsets defined by the HTTP mirror |
| // protocol will be resolve relative to the given URL. |
| // |
| // The given URL must use the "https" scheme, or this function will panic. |
| // (When the URL comes from user input, such as in the CLI config, it's the |
| // UI/config layer's responsibility to validate this and return a suitable |
| // error message for the end-user audience.) |
| func NewHTTPMirrorSource(baseURL *url.URL, creds svcauth.CredentialsSource) *HTTPMirrorSource { |
| httpClient := httpclient.New() |
| httpClient.Timeout = requestTimeout |
| httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { |
| // If we get redirected more than five times we'll assume we're |
| // in a redirect loop and bail out, rather than hanging forever. |
| if len(via) > 5 { |
| return fmt.Errorf("too many redirects") |
| } |
| return nil |
| } |
| return newHTTPMirrorSourceWithHTTPClient(baseURL, creds, httpClient) |
| } |
| |
| func newHTTPMirrorSourceWithHTTPClient(baseURL *url.URL, creds svcauth.CredentialsSource, httpClient *http.Client) *HTTPMirrorSource { |
| if baseURL.Scheme != "https" { |
| panic("non-https URL for HTTP mirror") |
| } |
| |
| // We borrow the retry settings and behaviors from the registry client, |
| // because our needs here are very similar to those of the registry client. |
| retryableClient := retryablehttp.NewClient() |
| retryableClient.HTTPClient = httpClient |
| retryableClient.RetryMax = discoveryRetry |
| retryableClient.RequestLogHook = requestLogHook |
| retryableClient.ErrorHandler = maxRetryErrorHandler |
| |
| retryableClient.Logger = log.New(logging.LogOutput(), "", log.Flags()) |
| |
| return &HTTPMirrorSource{ |
| baseURL: baseURL, |
| creds: creds, |
| httpClient: retryableClient, |
| } |
| } |
| |
| // AvailableVersions retrieves the available versions for the given provider |
| // from the object's underlying HTTP mirror service. |
| func (s *HTTPMirrorSource) AvailableVersions(ctx context.Context, provider addrs.Provider) (VersionList, Warnings, error) { |
| log.Printf("[DEBUG] Querying available versions of provider %s at network mirror %s", provider.String(), s.baseURL.String()) |
| |
| endpointPath := path.Join( |
| provider.Hostname.String(), |
| provider.Namespace, |
| provider.Type, |
| "index.json", |
| ) |
| |
| statusCode, body, finalURL, err := s.get(ctx, endpointPath) |
| defer func() { |
| if body != nil { |
| body.Close() |
| } |
| }() |
| if err != nil { |
| return nil, nil, s.errQueryFailed(provider, err) |
| } |
| |
| switch statusCode { |
| case http.StatusOK: |
| // Great! |
| case http.StatusNotFound: |
| return nil, nil, ErrProviderNotFound{ |
| Provider: provider, |
| } |
| case http.StatusUnauthorized, http.StatusForbidden: |
| return nil, nil, s.errUnauthorized(finalURL) |
| default: |
| return nil, nil, s.errQueryFailed(provider, fmt.Errorf("server returned unsuccessful status %d", statusCode)) |
| } |
| |
| // If we got here then the response had status OK and so our body |
| // will be non-nil and should contain some JSON for us to parse. |
| type ResponseBody struct { |
| Versions map[string]struct{} `json:"versions"` |
| } |
| var bodyContent ResponseBody |
| |
| dec := json.NewDecoder(body) |
| if err := dec.Decode(&bodyContent); err != nil { |
| return nil, nil, s.errQueryFailed(provider, fmt.Errorf("invalid response content from mirror server: %s", err)) |
| } |
| |
| if len(bodyContent.Versions) == 0 { |
| return nil, nil, nil |
| } |
| ret := make(VersionList, 0, len(bodyContent.Versions)) |
| for versionStr := range bodyContent.Versions { |
| version, err := ParseVersion(versionStr) |
| if err != nil { |
| log.Printf("[WARN] Ignoring invalid %s version string %q in provider mirror response", provider, versionStr) |
| continue |
| } |
| ret = append(ret, version) |
| } |
| |
| ret.Sort() |
| return ret, nil, nil |
| } |
| |
| // PackageMeta retrieves metadata for the requested provider package |
| // from the object's underlying HTTP mirror service. |
| func (s *HTTPMirrorSource) PackageMeta(ctx context.Context, provider addrs.Provider, version Version, target Platform) (PackageMeta, error) { |
| log.Printf("[DEBUG] Finding package URL for %s v%s on %s via network mirror %s", provider.String(), version.String(), target.String(), s.baseURL.String()) |
| |
| endpointPath := path.Join( |
| provider.Hostname.String(), |
| provider.Namespace, |
| provider.Type, |
| version.String()+".json", |
| ) |
| |
| statusCode, body, finalURL, err := s.get(ctx, endpointPath) |
| defer func() { |
| if body != nil { |
| body.Close() |
| } |
| }() |
| if err != nil { |
| return PackageMeta{}, s.errQueryFailed(provider, err) |
| } |
| |
| switch statusCode { |
| case http.StatusOK: |
| // Great! |
| case http.StatusNotFound: |
| // A 404 Not Found for a version we previously saw in index.json is |
| // a protocol error, so we'll report this as "query failed. |
| return PackageMeta{}, s.errQueryFailed(provider, fmt.Errorf("provider mirror does not have archive index for previously-reported %s version %s", provider, version)) |
| case http.StatusUnauthorized, http.StatusForbidden: |
| return PackageMeta{}, s.errUnauthorized(finalURL) |
| default: |
| return PackageMeta{}, s.errQueryFailed(provider, fmt.Errorf("server returned unsuccessful status %d", statusCode)) |
| } |
| |
| // If we got here then the response had status OK and so our body |
| // will be non-nil and should contain some JSON for us to parse. |
| type ResponseArchiveMeta struct { |
| RelativeURL string `json:"url"` |
| Hashes []string |
| } |
| type ResponseBody struct { |
| Archives map[string]*ResponseArchiveMeta `json:"archives"` |
| } |
| var bodyContent ResponseBody |
| |
| dec := json.NewDecoder(body) |
| if err := dec.Decode(&bodyContent); err != nil { |
| return PackageMeta{}, s.errQueryFailed(provider, fmt.Errorf("invalid response content from mirror server: %s", err)) |
| } |
| |
| archiveMeta, ok := bodyContent.Archives[target.String()] |
| if !ok { |
| return PackageMeta{}, ErrPlatformNotSupported{ |
| Provider: provider, |
| Version: version, |
| Platform: target, |
| MirrorURL: s.baseURL, |
| } |
| } |
| |
| relURL, err := url.Parse(archiveMeta.RelativeURL) |
| if err != nil { |
| return PackageMeta{}, s.errQueryFailed( |
| provider, |
| fmt.Errorf("provider mirror returned invalid URL %q: %s", archiveMeta.RelativeURL, err), |
| ) |
| } |
| absURL := finalURL.ResolveReference(relURL) |
| |
| ret := PackageMeta{ |
| Provider: provider, |
| Version: version, |
| TargetPlatform: target, |
| |
| Location: PackageHTTPURL(absURL.String()), |
| Filename: path.Base(absURL.Path), |
| } |
| // A network mirror might not provide any hashes at all, in which case |
| // the package has no source-defined authentication whatsoever. |
| if len(archiveMeta.Hashes) > 0 { |
| hashes := make([]Hash, 0, len(archiveMeta.Hashes)) |
| for _, hashStr := range archiveMeta.Hashes { |
| hash, err := ParseHash(hashStr) |
| if err != nil { |
| return PackageMeta{}, s.errQueryFailed( |
| provider, |
| fmt.Errorf("provider mirror returned invalid provider hash %q: %s", hashStr, err), |
| ) |
| } |
| hashes = append(hashes, hash) |
| } |
| ret.Authentication = NewPackageHashAuthentication(target, hashes) |
| } |
| |
| return ret, nil |
| } |
| |
| // ForDisplay returns a string description of the source for user-facing output. |
| func (s *HTTPMirrorSource) ForDisplay(provider addrs.Provider) string { |
| return "provider mirror at " + s.baseURL.String() |
| } |
| |
| // mirrorHost extracts the hostname portion of the configured base URL and |
| // returns it as a svchost.Hostname, normalized in the usual ways. |
| // |
| // If the returned error is non-nil then the given hostname doesn't comply |
| // with the IETF RFC 5891 section 5.3 and 5.4 validation rules, and thus cannot |
| // be interpreted as a valid Terraform service host. The IDNA validation errors |
| // are unfortunately usually not very user-friendly, but they are also |
| // relatively rare because the IDNA normalization rules are quite tolerant. |
| func (s *HTTPMirrorSource) mirrorHost() (svchost.Hostname, error) { |
| return svchostFromURL(s.baseURL) |
| } |
| |
| // mirrorHostCredentials returns the HostCredentials, if any, for the hostname |
| // included in the mirror base URL. |
| // |
| // It might return an error if the mirror base URL is invalid, or if the |
| // credentials lookup itself fails. |
| func (s *HTTPMirrorSource) mirrorHostCredentials() (svcauth.HostCredentials, error) { |
| hostname, err := s.mirrorHost() |
| if err != nil { |
| return nil, fmt.Errorf("invalid provider mirror base URL %s: %s", s.baseURL.String(), err) |
| } |
| |
| if s.creds == nil { |
| // No host-specific credentials, then. |
| return nil, nil |
| } |
| |
| return s.creds.ForHost(hostname) |
| } |
| |
| // get is the shared functionality for querying a JSON index from a mirror. |
| // |
| // It only handles the raw HTTP request. The "body" return value is the |
| // reader from the response if and only if the response status code is 200 OK |
| // and the Content-Type is application/json. In all other cases it's nil. |
| // If body is non-nil then the caller must close it after reading it. |
| // |
| // If the "finalURL" return value is not empty then it's the URL that actually |
| // produced the returned response, possibly after following some redirects. |
| func (s *HTTPMirrorSource) get(ctx context.Context, relativePath string) (statusCode int, body io.ReadCloser, finalURL *url.URL, error error) { |
| endpointPath, err := url.Parse(relativePath) |
| if err != nil { |
| // Should never happen because the caller should validate all of the |
| // components it's including in the path. |
| return 0, nil, nil, err |
| } |
| endpointURL := s.baseURL.ResolveReference(endpointPath) |
| |
| req, err := retryablehttp.NewRequest("GET", endpointURL.String(), nil) |
| if err != nil { |
| return 0, nil, endpointURL, err |
| } |
| req = req.WithContext(ctx) |
| req.Request.Header.Set(terraformVersionHeader, version.String()) |
| creds, err := s.mirrorHostCredentials() |
| if err != nil { |
| return 0, nil, endpointURL, fmt.Errorf("failed to determine request credentials: %s", err) |
| } |
| if creds != nil { |
| // Note that if the initial requests gets redirected elsewhere |
| // then the credentials will still be included in the new request, |
| // even if they are on a different hostname. This is intentional |
| // and consistent with how we handle credentials for other |
| // Terraform-native services, because the user model is to configure |
| // credentials for the "friendly hostname" they configured, not for |
| // whatever hostname ends up ultimately serving the request as an |
| // implementation detail. |
| creds.PrepareRequest(req.Request) |
| } |
| |
| resp, err := s.httpClient.Do(req) |
| if err != nil { |
| return 0, nil, endpointURL, err |
| } |
| defer func() { |
| // If we're not returning the body then we'll close it |
| // before we return. |
| if body == nil { |
| resp.Body.Close() |
| } |
| }() |
| // After this point, our final URL return value should always be the |
| // one from resp.Request, because that takes into account any redirects |
| // we followed along the way. |
| finalURL = resp.Request.URL |
| |
| if resp.StatusCode == http.StatusOK { |
| // If and only if we get an OK response, we'll check that the response |
| // type is JSON and return the body reader. |
| ct := resp.Header.Get("Content-Type") |
| mt, params, err := mime.ParseMediaType(ct) |
| if err != nil { |
| return 0, nil, finalURL, fmt.Errorf("response has invalid Content-Type: %s", err) |
| } |
| if mt != "application/json" { |
| return 0, nil, finalURL, fmt.Errorf("response has invalid Content-Type: must be application/json") |
| } |
| for name := range params { |
| // The application/json content-type has no defined parameters, |
| // but some servers are configured to include a redundant "charset" |
| // parameter anyway, presumably out of a sense of completeness. |
| // We'll ignore them but warn that we're ignoring them in case the |
| // subsequent parsing fails due to the server trying to use an |
| // unsupported character encoding. (RFC 7159 defines its own |
| // JSON-specific character encoding rules.) |
| log.Printf("[WARN] Network mirror returned %q as part of its JSON content type, which is not defined. Ignoring.", name) |
| } |
| body = resp.Body |
| } |
| |
| return resp.StatusCode, body, finalURL, nil |
| } |
| |
| func (s *HTTPMirrorSource) errQueryFailed(provider addrs.Provider, err error) error { |
| if err == context.Canceled { |
| // This one has a special error type so that callers can |
| // handle it in a different way. |
| return ErrRequestCanceled{} |
| } |
| return ErrQueryFailed{ |
| Provider: provider, |
| Wrapped: err, |
| MirrorURL: s.baseURL, |
| } |
| } |
| |
| func (s *HTTPMirrorSource) errUnauthorized(finalURL *url.URL) error { |
| hostname, err := svchostFromURL(finalURL) |
| if err != nil { |
| // Again, weird but we'll tolerate it. |
| return fmt.Errorf("invalid credentials for %s", finalURL) |
| } |
| |
| return ErrUnauthorized{ |
| Hostname: hostname, |
| |
| // We can't easily tell from here whether we had credentials or |
| // not, so for now we'll just assume we did because "host rejected |
| // the given credentials" is, hopefully, still understandable in |
| // the event that there were none. (If this ends up being confusing |
| // in practice then we'll need to do some refactoring of how |
| // we handle credentials in this source.) |
| HaveCredentials: true, |
| } |
| } |
| |
| func svchostFromURL(u *url.URL) (svchost.Hostname, error) { |
| raw := u.Host |
| |
| // When "friendly hostnames" appear in Terraform-specific identifiers we |
| // typically constrain their syntax more strictly than the |
| // Internationalized Domain Name specifications call for, such as |
| // forbidding direct use of punycode, but in this case we're just |
| // working with a standard http: or https: URL and so we'll first use the |
| // IDNA "lookup" rules directly, with no additional notational constraints, |
| // to effectively normalize away the differences that would normally |
| // produce an error. |
| var portPortion string |
| if colonPos := strings.Index(raw, ":"); colonPos != -1 { |
| raw, portPortion = raw[:colonPos], raw[colonPos:] |
| } |
| // HTTPMirrorSource requires all URLs to be https URLs, because running |
| // a network mirror over HTTP would potentially transmit any configured |
| // credentials in cleartext. Therefore we don't need to do any special |
| // handling of default ports here, because svchost.Hostname already |
| // considers the absense of a port to represent the standard HTTPS port |
| // 443, and will normalize away an explicit specification of port 443 |
| // in svchost.ForComparison below. |
| |
| normalized, err := idna.Display.ToUnicode(raw) |
| if err != nil { |
| return svchost.Hostname(""), err |
| } |
| |
| // If ToUnicode succeeded above then "normalized" is now a hostname in the |
| // normalized IDNA form, with any direct punycode already interpreted and |
| // the case folding and other normalization rules applied. It should |
| // therefore now be accepted by svchost.ForComparison with no additional |
| // errors, but the port portion can still potentially be invalid. |
| return svchost.ForComparison(normalized + portPortion) |
| } |