pax_global_header00006660000000000000000000000064143636264070014525gustar00rootroot0000000000000052 comment=1c9bf6f1d8d8db9fe3b2da0683ad9ce63245e28c terraform-svchost-0.0.1/000077500000000000000000000000001436362640700152135ustar00rootroot00000000000000terraform-svchost-0.0.1/.github/000077500000000000000000000000001436362640700165535ustar00rootroot00000000000000terraform-svchost-0.0.1/.github/dependabot.yml000066400000000000000000000002131436362640700213770ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "gomod" directory: "/" schedule: interval: "monthly" labels: ["dependencies"] terraform-svchost-0.0.1/.github/pull_request_template.md000066400000000000000000000013161436362640700235150ustar00rootroot00000000000000 ## Description ## Testing plan ## External links terraform-svchost-0.0.1/.github/workflows/000077500000000000000000000000001436362640700206105ustar00rootroot00000000000000terraform-svchost-0.0.1/.github/workflows/ci.yml000066400000000000000000000017411436362640700217310ustar00rootroot00000000000000name: CI Tests on: pull_request: branches: - main push: branches: - main jobs: test: runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Checkout uses: actions/checkout@v3 - name: Set up Go uses: actions/setup-go@v3 with: go-version-file: go.mod cache: true - name: Get dependences run: go mod download - name: Verify go.mod and go.sum are consistent run: go mod tidy - name: Ensure nothing changed run: git diff --exit-code - name: Check format run: | go fmt ./... if [[ -z "$(git status --porcelain)" ]]; then echo "Formatting is consistent with 'go fmt'." else echo "Run 'go fmt ./...' to automatically apply standard Go style to all packages." git status --porcelain exit 1 fi - name: Run tests run: go test -v -race ./... terraform-svchost-0.0.1/LICENSE000066400000000000000000000372141436362640700162270ustar00rootroot00000000000000Copyright (c) 2019 HashiCorp, Inc. Mozilla Public License, version 2.0 1. Definitions 1.1. “Contributor” means each individual or legal entity that creates, contributes to the creation of, or owns Covered Software. 1.2. “Contributor Version” means the combination of the Contributions of others (if any) used by a Contributor and that particular Contributor’s Contribution. 1.3. “Contribution” means Covered Software of a particular Contributor. 1.4. “Covered Software” means Source Code Form to which the initial Contributor has attached the notice in Exhibit A, the Executable Form of such Source Code Form, and Modifications of such Source Code Form, in each case including portions thereof. 1.5. “Incompatible With Secondary Licenses” means a. that the initial Contributor has attached the notice described in Exhibit B to the Covered Software; or b. that the Covered Software was made available under the terms of version 1.1 or earlier of the License, but not also under the terms of a Secondary License. 1.6. “Executable Form” means any form of the work other than Source Code Form. 1.7. “Larger Work” means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software. 1.8. “License” means this document. 1.9. “Licensable” means having the right to grant, to the maximum extent possible, whether at the time of the initial grant or subsequently, any and all of the rights conveyed by this License. 1.10. “Modifications” means any of the following: a. any file in Source Code Form that results from an addition to, deletion from, or modification of the contents of Covered Software; or b. any new file in Source Code Form that contains any Covered Software. 1.11. “Patent Claims” of a Contributor means any patent claim(s), including without limitation, method, process, and apparatus claims, in any patent Licensable by such Contributor that would be infringed, but for the grant of the License, by the making, using, selling, offering for sale, having made, import, or transfer of either its Contributions or its Contributor Version. 1.12. “Secondary License” means either the GNU General Public License, Version 2.0, the GNU Lesser General Public License, Version 2.1, the GNU Affero General Public License, Version 3.0, or any later versions of those licenses. 1.13. “Source Code Form” means the form of the work preferred for making modifications. 1.14. “You” (or “Your”) means an individual or a legal entity exercising rights under this License. For legal entities, “You” includes any entity that controls, is controlled by, or is under common control with You. For purposes of this definition, “control” means (a) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (b) ownership of more than fifty percent (50%) of the outstanding shares or beneficial ownership of such entity. 2. License Grants and Conditions 2.1. Grants Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive license: a. under intellectual property rights (other than patent or trademark) Licensable by such Contributor to use, reproduce, make available, modify, display, perform, distribute, and otherwise exploit its Contributions, either on an unmodified basis, with Modifications, or as part of a Larger Work; and b. under Patent Claims of such Contributor to make, use, sell, offer for sale, have made, import, and otherwise transfer either its Contributions or its Contributor Version. 2.2. Effective Date The licenses granted in Section 2.1 with respect to any Contribution become effective for each Contribution on the date the Contributor first distributes such Contribution. 2.3. Limitations on Grant Scope The licenses granted in this Section 2 are the only rights granted under this License. No additional rights or licenses will be implied from the distribution or licensing of Covered Software under this License. Notwithstanding Section 2.1(b) above, no patent license is granted by a Contributor: a. for any code that a Contributor has removed from Covered Software; or b. for infringements caused by: (i) Your and any other third party’s modifications of Covered Software, or (ii) the combination of its Contributions with other software (except as part of its Contributor Version); or c. under Patent Claims infringed by Covered Software in the absence of its Contributions. This License does not grant any rights in the trademarks, service marks, or logos of any Contributor (except as may be necessary to comply with the notice requirements in Section 3.4). 2.4. Subsequent Licenses No Contributor makes additional grants as a result of Your choice to distribute the Covered Software under a subsequent version of this License (see Section 10.2) or under the terms of a Secondary License (if permitted under the terms of Section 3.3). 2.5. Representation Each Contributor represents that the Contributor believes its Contributions are its original creation(s) or it has sufficient rights to grant the rights to its Contributions conveyed by this License. 2.6. Fair Use This License is not intended to limit any rights You have under applicable copyright doctrines of fair use, fair dealing, or other equivalents. 2.7. Conditions Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in Section 2.1. 3. Responsibilities 3.1. Distribution of Source Form All distribution of Covered Software in Source Code Form, including any Modifications that You create or to which You contribute, must be under the terms of this License. You must inform recipients that the Source Code Form of the Covered Software is governed by the terms of this License, and how they can obtain a copy of this License. You may not attempt to alter or restrict the recipients’ rights in the Source Code Form. 3.2. Distribution of Executable Form If You distribute Covered Software in Executable Form then: a. such Covered Software must also be made available in Source Code Form, as described in Section 3.1, and You must inform recipients of the Executable Form how they can obtain a copy of such Source Code Form by reasonable means in a timely manner, at a charge no more than the cost of distribution to the recipient; and b. You may distribute such Executable Form under the terms of this License, or sublicense it under different terms, provided that the license for the Executable Form does not attempt to limit or alter the recipients’ rights in the Source Code Form under this License. 3.3. Distribution of a Larger Work You may create and distribute a Larger Work under terms of Your choice, provided that You also comply with the requirements of this License for the Covered Software. If the Larger Work is a combination of Covered Software with a work governed by one or more Secondary Licenses, and the Covered Software is not Incompatible With Secondary Licenses, this License permits You to additionally distribute such Covered Software under the terms of such Secondary License(s), so that the recipient of the Larger Work may, at their option, further distribute the Covered Software under the terms of either this License or such Secondary License(s). 3.4. Notices You may not remove or alter the substance of any license notices (including copyright notices, patent notices, disclaimers of warranty, or limitations of liability) contained within the Source Code Form of the Covered Software, except that You may alter any license notices to the extent required to remedy known factual inaccuracies. 3.5. Application of Additional Terms You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability obligations to one or more recipients of Covered Software. However, You may do so only on Your own behalf, and not on behalf of any Contributor. You must make it absolutely clear that any such warranty, support, indemnity, or liability obligation is offered by You alone, and You hereby agree to indemnify every Contributor for any liability incurred by such Contributor as a result of warranty, support, indemnity or liability terms You offer. You may include additional disclaimers of warranty and limitations of liability specific to any jurisdiction. 4. Inability to Comply Due to Statute or Regulation If it is impossible for You to comply with any of the terms of this License with respect to some or all of the Covered Software due to statute, judicial order, or regulation then You must: (a) comply with the terms of this License to the maximum extent possible; and (b) describe the limitations and the code they affect. Such description must be placed in a text file included with all distributions of the Covered Software under this License. Except to the extent prohibited by statute or regulation, such description must be sufficiently detailed for a recipient of ordinary skill to be able to understand it. 5. Termination 5.1. The rights granted under this License will terminate automatically if You fail to comply with any of its terms. However, if You become compliant, then the rights granted under this License from a particular Contributor are reinstated (a) provisionally, unless and until such Contributor explicitly and finally terminates Your grants, and (b) on an ongoing basis, if such Contributor fails to notify You of the non-compliance by some reasonable means prior to 60 days after You have come back into compliance. Moreover, Your grants from a particular Contributor are reinstated on an ongoing basis if such Contributor notifies You of the non-compliance by some reasonable means, this is the first time You have received notice of non-compliance with this License from such Contributor, and You become compliant prior to 30 days after Your receipt of the notice. 5.2. If You initiate litigation against any entity by asserting a patent infringement claim (excluding declaratory judgment actions, counter-claims, and cross-claims) alleging that a Contributor Version directly or indirectly infringes any patent, then the rights granted to You by any and all Contributors for the Covered Software under Section 2.1 of this License shall terminate. 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user license agreements (excluding distributors and resellers) which have been validly granted by You or Your distributors under this License prior to termination shall survive termination. 6. Disclaimer of Warranty Covered Software is provided under this License on an “as is” basis, without warranty of any kind, either expressed, implied, or statutory, including, without limitation, warranties that the Covered Software is free of defects, merchantable, fit for a particular purpose or non-infringing. The entire risk as to the quality and performance of the Covered Software is with You. Should any Covered Software prove defective in any respect, You (not any Contributor) assume the cost of any necessary servicing, repair, or correction. This disclaimer of warranty constitutes an essential part of this License. No use of any Covered Software is authorized under this License except under this disclaimer. 7. Limitation of Liability Under no circumstances and under no legal theory, whether tort (including negligence), contract, or otherwise, shall any Contributor, or anyone who distributes Covered Software as permitted above, be liable to You for any direct, indirect, special, incidental, or consequential damages of any character including, without limitation, damages for lost profits, loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses, even if such party shall have been informed of the possibility of such damages. This limitation of liability shall not apply to liability for death or personal injury resulting from such party’s negligence to the extent applicable law prohibits such limitation. Some jurisdictions do not allow the exclusion or limitation of incidental or consequential damages, so this exclusion and limitation may not apply to You. 8. Litigation Any litigation relating to this License may be brought only in the courts of a jurisdiction where the defendant maintains its principal place of business and such litigation shall be governed by laws of that jurisdiction, without reference to its conflict-of-law provisions. Nothing in this Section shall prevent a party’s ability to bring cross-claims or counter-claims. 9. Miscellaneous This License represents the complete agreement concerning the subject matter hereof. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. Any law or regulation which provides that the language of a contract shall be construed against the drafter shall not be used to construe this License against a Contributor. 10. Versions of the License 10.1. New Versions Mozilla Foundation is the license steward. Except as provided in Section 10.3, no one other than the license steward has the right to modify or publish new versions of this License. Each version will be given a distinguishing version number. 10.2. Effect of New Versions You may distribute the Covered Software under the terms of the version of the License under which You originally received the Covered Software, or under the terms of any subsequent version published by the license steward. 10.3. Modified Versions If you create software not governed by this License, and you want to create a new license for such software, you may create and use a modified version of this License if you rename the license and remove any references to the name of the license steward (except to note that such modified license differs from this License). 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses If You choose to distribute Source Code Form that is Incompatible With Secondary Licenses under the terms of this version of the License, the notice described in Exhibit B of this License must be attached. Exhibit A - Source Code Form License Notice This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. If it is not possible or desirable to put the notice in a particular file, then You may include the notice in a location (such as a LICENSE file in a relevant directory) where a recipient would be likely to look for such a notice. You may add additional accurate notices of copyright ownership. Exhibit B - “Incompatible With Secondary Licenses” Notice This Source Code Form is “Incompatible With Secondary Licenses”, as defined by the Mozilla Public License, v. 2.0. terraform-svchost-0.0.1/README.md000066400000000000000000000016171436362640700164770ustar00rootroot00000000000000# Terraform svchost package [![CI Tests](https://github.com/hashicorp/terraform-svchost/actions/workflows/ci.yml/badge.svg)](https://github.com/hashicorp/terraform-svchost/actions/workflows/ci.yml) [![GitHub license](https://img.shields.io/github/license/hashicorp/terraform-svchost.svg)](https://github.com/hashicorp/terraform-svchost/blob/main/LICENSE) [![GoDoc](https://godoc.org/github.com/hashicorp/terraform-svchost?status.svg)](https://godoc.org/github.com/hashicorp/terraform-svchost) [![Go Report Card](https://goreportcard.com/badge/github.com/hashicorp/terraform-svchost)](https://goreportcard.com/report/github.com/hashicorp/terraform-svchost) [![GitHub issues](https://img.shields.io/github/issues/hashicorp/terraform-svchost.svg)](https://github.com/hashicorp/terraform-svchost/issues) This package provides friendly hostnames, and is used by [terraform](https://github.com/hashicorp/terraform).terraform-svchost-0.0.1/auth/000077500000000000000000000000001436362640700161545ustar00rootroot00000000000000terraform-svchost-0.0.1/auth/cache.go000066400000000000000000000041271436362640700175520ustar00rootroot00000000000000package auth import ( "github.com/hashicorp/terraform-svchost" ) // CachingCredentialsSource creates a new credentials source that wraps another // and caches its results in memory, on a per-hostname basis. // // No means is provided for expiration of cached credentials, so a caching // credentials source should have a limited lifetime (one Terraform operation, // for example) to ensure that time-limited credentials don't expire before // their cache entries do. func CachingCredentialsSource(source CredentialsSource) CredentialsSource { return &cachingCredentialsSource{ source: source, cache: map[svchost.Hostname]HostCredentials{}, } } type cachingCredentialsSource struct { source CredentialsSource cache map[svchost.Hostname]HostCredentials } // ForHost passes the given hostname on to the wrapped credentials source and // caches the result to return for future requests with the same hostname. // // Both credentials and non-credentials (nil) responses are cached. // // No cache entry is created if the wrapped source returns an error, to allow // the caller to retry the failing operation. func (s *cachingCredentialsSource) ForHost(host svchost.Hostname) (HostCredentials, error) { if cache, cached := s.cache[host]; cached { return cache, nil } result, err := s.source.ForHost(host) if err != nil { return result, err } s.cache[host] = result return result, nil } func (s *cachingCredentialsSource) StoreForHost(host svchost.Hostname, credentials HostCredentialsWritable) error { // We'll delete the cache entry even if the store fails, since that just // means that the next read will go to the real store and get a chance to // see which object (old or new) is actually present. delete(s.cache, host) return s.source.StoreForHost(host, credentials) } func (s *cachingCredentialsSource) ForgetForHost(host svchost.Hostname) error { // We'll delete the cache entry even if the store fails, since that just // means that the next read will go to the real store and get a chance to // see if the object is still present. delete(s.cache, host) return s.source.ForgetForHost(host) } terraform-svchost-0.0.1/auth/credentials.go000066400000000000000000000103161436362640700210010ustar00rootroot00000000000000// Package auth contains types and functions to manage authentication // credentials for service hosts. package auth import ( "fmt" "net/http" "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform-svchost" ) // Credentials is a list of CredentialsSource objects that can be tried in // turn until one returns credentials for a host, or one returns an error. // // A Credentials is itself a CredentialsSource, wrapping its members. // In principle one CredentialsSource can be nested inside another, though // there is no good reason to do so. // // The write operations on a Credentials are tried only on the first object, // under the assumption that it is the primary store. type Credentials []CredentialsSource // NoCredentials is an empty CredentialsSource that always returns nil // when asked for credentials. var NoCredentials CredentialsSource = Credentials{} // A CredentialsSource is an object that may be able to provide credentials // for a given host. // // Credentials lookups are not guaranteed to be concurrency-safe. Callers // using these facilities in concurrent code must use external concurrency // primitives to prevent race conditions. type CredentialsSource interface { // ForHost returns a non-nil HostCredentials if the source has credentials // available for the host, and a nil HostCredentials if it does not. // // If an error is returned, progress through a list of CredentialsSources // is halted and the error is returned to the user. ForHost(host svchost.Hostname) (HostCredentials, error) // StoreForHost takes a HostCredentialsWritable and saves it as the // credentials for the given host. // // If credentials are already stored for the given host, it will try to // replace those credentials but may produce an error if such replacement // is not possible. StoreForHost(host svchost.Hostname, credentials HostCredentialsWritable) error // ForgetForHost discards any stored credentials for the given host. It // does nothing and returns successfully if no credentials are saved // for that host. ForgetForHost(host svchost.Hostname) error } // HostCredentials represents a single set of credentials for a particular // host. type HostCredentials interface { // PrepareRequest modifies the given request in-place to apply the // receiving credentials. The usual behavior of this method is to // add some sort of Authorization header to the request. PrepareRequest(req *http.Request) // Token returns the authentication token. Token() string } // HostCredentialsWritable is an extension of HostCredentials for credentials // objects that can be serialized as a JSON-compatible object value for // storage. type HostCredentialsWritable interface { HostCredentials // ToStore returns a cty.Value, always of an object type, // representing data that can be serialized to represent this object // in persistent storage. // // The resulting value may uses only cty values that can be accepted // by the cty JSON encoder, though the caller may elect to instead store // it in some other format that has a JSON-compatible type system. ToStore() cty.Value } // ForHost iterates over the contained CredentialsSource objects and // tries to obtain credentials for the given host from each one in turn. // // If any source returns either a non-nil HostCredentials or a non-nil error // then this result is returned. Otherwise, the result is nil, nil. func (c Credentials) ForHost(host svchost.Hostname) (HostCredentials, error) { for _, source := range c { creds, err := source.ForHost(host) if creds != nil || err != nil { return creds, err } } return nil, nil } // StoreForHost passes the given arguments to the same operation on the // first CredentialsSource in the receiver. func (c Credentials) StoreForHost(host svchost.Hostname, credentials HostCredentialsWritable) error { if len(c) == 0 { return fmt.Errorf("no credentials store is available") } return c[0].StoreForHost(host, credentials) } // ForgetForHost passes the given arguments to the same operation on the // first CredentialsSource in the receiver. func (c Credentials) ForgetForHost(host svchost.Hostname) error { if len(c) == 0 { return fmt.Errorf("no credentials store is available") } return c[0].ForgetForHost(host) } terraform-svchost-0.0.1/auth/from_map.go000066400000000000000000000030221436362640700203000ustar00rootroot00000000000000package auth import ( "github.com/zclconf/go-cty/cty" ) // HostCredentialsFromMap converts a map of key-value pairs from a credentials // definition provided by the user (e.g. in a config file, or via a credentials // helper) into a HostCredentials object if possible, or returns nil if // no credentials could be extracted from the map. // // This function ignores map keys it is unfamiliar with, to allow for future // expansion of the credentials map format for new credential types. func HostCredentialsFromMap(m map[string]interface{}) HostCredentials { if m == nil { return nil } if token, ok := m["token"].(string); ok { return HostCredentialsToken(token) } return nil } // HostCredentialsFromObject converts a cty.Value of an object type into a // HostCredentials object if possible, or returns nil if no credentials could // be extracted from the map. // // This function ignores object attributes it is unfamiliar with, to allow for // future expansion of the credentials object structure for new credential types. // // If the given value is not of an object type, this function will panic. func HostCredentialsFromObject(obj cty.Value) HostCredentials { if !obj.Type().HasAttribute("token") { return nil } tokenV := obj.GetAttr("token") if tokenV.IsNull() || !tokenV.IsKnown() { return nil } if !cty.String.Equals(tokenV.Type()) { // Weird, but maybe some future Terraform version accepts an object // here for some reason, so we'll be resilient. return nil } return HostCredentialsToken(tokenV.AsString()) } terraform-svchost-0.0.1/auth/helper_program.go000066400000000000000000000102371436362640700215140ustar00rootroot00000000000000package auth import ( "bytes" "encoding/json" "fmt" "os/exec" "path/filepath" ctyjson "github.com/zclconf/go-cty/cty/json" "github.com/hashicorp/terraform-svchost" ) type helperProgramCredentialsSource struct { executable string args []string } // HelperProgramCredentialsSource returns a CredentialsSource that runs the // given program with the given arguments in order to obtain credentials. // // The given executable path must be an absolute path; it is the caller's // responsibility to validate and process a relative path or other input // provided by an end-user. If the given path is not absolute, this // function will panic. // // When credentials are requested, the program will be run in a child process // with the given arguments along with two additional arguments added to the // end of the list: the literal string "get", followed by the requested // hostname in ASCII compatibility form (punycode form). func HelperProgramCredentialsSource(executable string, args ...string) CredentialsSource { if !filepath.IsAbs(executable) { panic("NewCredentialsSourceHelperProgram requires absolute path to executable") } fullArgs := make([]string, len(args)+1) fullArgs[0] = executable copy(fullArgs[1:], args) return &helperProgramCredentialsSource{ executable: executable, args: fullArgs, } } func (s *helperProgramCredentialsSource) ForHost(host svchost.Hostname) (HostCredentials, error) { args := make([]string, len(s.args), len(s.args)+2) copy(args, s.args) args = append(args, "get") args = append(args, string(host)) outBuf := bytes.Buffer{} errBuf := bytes.Buffer{} cmd := exec.Cmd{ Path: s.executable, Args: args, Stdin: nil, Stdout: &outBuf, Stderr: &errBuf, } err := cmd.Run() if _, isExitErr := err.(*exec.ExitError); isExitErr { errText := errBuf.String() if errText == "" { // Shouldn't happen for a well-behaved helper program return nil, fmt.Errorf("error in %s, but it produced no error message", s.executable) } return nil, fmt.Errorf("error in %s: %s", s.executable, errText) } else if err != nil { return nil, fmt.Errorf("failed to run %s: %s", s.executable, err) } var m map[string]interface{} err = json.Unmarshal(outBuf.Bytes(), &m) if err != nil { return nil, fmt.Errorf("malformed output from %s: %s", s.executable, err) } return HostCredentialsFromMap(m), nil } func (s *helperProgramCredentialsSource) StoreForHost(host svchost.Hostname, credentials HostCredentialsWritable) error { args := make([]string, len(s.args), len(s.args)+2) copy(args, s.args) args = append(args, "store") args = append(args, string(host)) toStore := credentials.ToStore() toStoreRaw, err := ctyjson.Marshal(toStore, toStore.Type()) if err != nil { return fmt.Errorf("can't serialize credentials to store: %s", err) } inReader := bytes.NewReader(toStoreRaw) errBuf := bytes.Buffer{} cmd := exec.Cmd{ Path: s.executable, Args: args, Stdin: inReader, Stderr: &errBuf, Stdout: nil, } err = cmd.Run() if _, isExitErr := err.(*exec.ExitError); isExitErr { errText := errBuf.String() if errText == "" { // Shouldn't happen for a well-behaved helper program return fmt.Errorf("error in %s, but it produced no error message", s.executable) } return fmt.Errorf("error in %s: %s", s.executable, errText) } else if err != nil { return fmt.Errorf("failed to run %s: %s", s.executable, err) } return nil } func (s *helperProgramCredentialsSource) ForgetForHost(host svchost.Hostname) error { args := make([]string, len(s.args), len(s.args)+2) copy(args, s.args) args = append(args, "forget") args = append(args, string(host)) errBuf := bytes.Buffer{} cmd := exec.Cmd{ Path: s.executable, Args: args, Stdin: nil, Stderr: &errBuf, Stdout: nil, } err := cmd.Run() if _, isExitErr := err.(*exec.ExitError); isExitErr { errText := errBuf.String() if errText == "" { // Shouldn't happen for a well-behaved helper program return fmt.Errorf("error in %s, but it produced no error message", s.executable) } return fmt.Errorf("error in %s: %s", s.executable, errText) } else if err != nil { return fmt.Errorf("failed to run %s: %s", s.executable, err) } return nil } terraform-svchost-0.0.1/auth/helper_program_test.go000066400000000000000000000041561436362640700225560ustar00rootroot00000000000000package auth import ( "os" "path/filepath" "testing" "github.com/hashicorp/terraform-svchost" ) func TestHelperProgramCredentialsSource(t *testing.T) { wd, err := os.Getwd() if err != nil { t.Fatal(err) } program := filepath.Join(wd, "testdata/test-helper") t.Logf("testing with helper at %s", program) src := HelperProgramCredentialsSource(program) t.Run("happy path", func(t *testing.T) { creds, err := src.ForHost(svchost.Hostname("example.com")) if err != nil { t.Fatal(err) } if tokCreds, isTok := creds.(HostCredentialsToken); isTok { if got, want := string(tokCreds), "example-token"; got != want { t.Errorf("wrong token %q; want %q", got, want) } } else { t.Errorf("wrong type of credentials %T", creds) } }) t.Run("no credentials", func(t *testing.T) { creds, err := src.ForHost(svchost.Hostname("nothing.example.com")) if err != nil { t.Fatal(err) } if creds != nil { t.Errorf("got credentials; want nil") } }) t.Run("unsupported credentials type", func(t *testing.T) { creds, err := src.ForHost(svchost.Hostname("other-cred-type.example.com")) if err != nil { t.Fatal(err) } if creds != nil { t.Errorf("got credentials; want nil") } }) t.Run("lookup error", func(t *testing.T) { _, err := src.ForHost(svchost.Hostname("fail.example.com")) if err == nil { t.Error("completed successfully; want error") } }) t.Run("store happy path", func(t *testing.T) { err := src.StoreForHost(svchost.Hostname("example.com"), HostCredentialsToken("example-token")) if err != nil { t.Fatal(err) } }) t.Run("store error", func(t *testing.T) { err := src.StoreForHost(svchost.Hostname("fail.example.com"), HostCredentialsToken("example-token")) if err == nil { t.Error("completed successfully; want error") } }) t.Run("forget happy path", func(t *testing.T) { err := src.ForgetForHost(svchost.Hostname("example.com")) if err != nil { t.Fatal(err) } }) t.Run("forget error", func(t *testing.T) { err := src.ForgetForHost(svchost.Hostname("fail.example.com")) if err == nil { t.Error("completed successfully; want error") } }) } terraform-svchost-0.0.1/auth/static.go000066400000000000000000000021571436362640700177770ustar00rootroot00000000000000package auth import ( "fmt" "github.com/hashicorp/terraform-svchost" ) // StaticCredentialsSource is a credentials source that retrieves credentials // from the provided map. It returns nil if a requested hostname is not // present in the map. // // The caller should not modify the given map after passing it to this function. func StaticCredentialsSource(creds map[svchost.Hostname]map[string]interface{}) CredentialsSource { return staticCredentialsSource(creds) } type staticCredentialsSource map[svchost.Hostname]map[string]interface{} func (s staticCredentialsSource) ForHost(host svchost.Hostname) (HostCredentials, error) { if s == nil { return nil, nil } if m, exists := s[host]; exists { return HostCredentialsFromMap(m), nil } return nil, nil } func (s staticCredentialsSource) StoreForHost(host svchost.Hostname, credentials HostCredentialsWritable) error { return fmt.Errorf("can't store new credentials in a static credentials source") } func (s staticCredentialsSource) ForgetForHost(host svchost.Hostname) error { return fmt.Errorf("can't discard credentials from a static credentials source") } terraform-svchost-0.0.1/auth/static_test.go000066400000000000000000000016261436362640700210360ustar00rootroot00000000000000package auth import ( "testing" "github.com/hashicorp/terraform-svchost" ) func TestStaticCredentialsSource(t *testing.T) { src := StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{ svchost.Hostname("example.com"): map[string]interface{}{ "token": "abc123", }, }) t.Run("exists", func(t *testing.T) { creds, err := src.ForHost(svchost.Hostname("example.com")) if err != nil { t.Fatal(err) } if tokCreds, isToken := creds.(HostCredentialsToken); isToken { if got, want := string(tokCreds), "abc123"; got != want { t.Errorf("wrong token %q; want %q", got, want) } } else { t.Errorf("creds is %#v; want HostCredentialsToken", creds) } }) t.Run("does not exist", func(t *testing.T) { creds, err := src.ForHost(svchost.Hostname("example.net")) if err != nil { t.Fatal(err) } if creds != nil { t.Errorf("creds is %#v; want nil", creds) } }) } terraform-svchost-0.0.1/auth/testdata/000077500000000000000000000000001436362640700177655ustar00rootroot00000000000000terraform-svchost-0.0.1/auth/testdata/.gitignore000066400000000000000000000000051436362640700217500ustar00rootroot00000000000000main terraform-svchost-0.0.1/auth/testdata/main.go000066400000000000000000000024631436362640700212450ustar00rootroot00000000000000package main import ( "encoding/json" "fmt" "io/ioutil" "os" ) // This is a simple program that implements the "helper program" protocol // for the svchost/auth package for unit testing purposes. func main() { args := os.Args if len(args) < 3 { die("not enough arguments\n") } host := args[2] switch args[1] { case "get": switch host { case "example.com": fmt.Print(`{"token":"example-token"}`) case "other-cred-type.example.com": fmt.Print(`{"username":"alfred"}`) // unrecognized by main program case "fail.example.com": die("failing because you told me to fail\n") default: fmt.Print("{}") // no credentials available } case "store": dataSrc, err := ioutil.ReadAll(os.Stdin) if err != nil { die("invalid input: %s", err) } var data map[string]interface{} err = json.Unmarshal(dataSrc, &data) switch host { case "example.com": if data["token"] != "example-token" { die("incorrect token value to store") } default: die("can't store credentials for %s", host) } case "forget": switch host { case "example.com": // okay! default: die("can't forget credentials for %s", host) } default: die("unknown subcommand %q\n", args[1]) } } func die(f string, args ...interface{}) { fmt.Fprintf(os.Stderr, fmt.Sprintf(f, args...)) os.Exit(1) } terraform-svchost-0.0.1/auth/testdata/test-helper000077500000000000000000000001671436362640700221530ustar00rootroot00000000000000#!/usr/bin/env bash set -eu cd "$( dirname "${BASH_SOURCE[0]}" )" [ -x main ] || go build -o main . exec ./main "$@" terraform-svchost-0.0.1/auth/token_credentials.go000066400000000000000000000026041436362640700222020ustar00rootroot00000000000000package auth import ( "net/http" "github.com/zclconf/go-cty/cty" ) // HostCredentialsToken is a HostCredentials implementation that represents a // single "bearer token", to be sent to the server via an Authorization header // with the auth type set to "Bearer". // // To save a token as the credentials for a host, convert the token string to // this type and use the result as a HostCredentialsWritable implementation. type HostCredentialsToken string // Interface implementation assertions. Compilation will fail here if // HostCredentialsToken does not fully implement these interfaces. var _ HostCredentials = HostCredentialsToken("") var _ HostCredentialsWritable = HostCredentialsToken("") // PrepareRequest alters the given HTTP request by setting its Authorization // header to the string "Bearer " followed by the encapsulated authentication // token. func (tc HostCredentialsToken) PrepareRequest(req *http.Request) { if req.Header == nil { req.Header = http.Header{} } req.Header.Set("Authorization", "Bearer "+string(tc)) } // Token returns the authentication token. func (tc HostCredentialsToken) Token() string { return string(tc) } // ToStore returns a credentials object with a single attribute "token" whose // value is the token string. func (tc HostCredentialsToken) ToStore() cty.Value { return cty.ObjectVal(map[string]cty.Value{ "token": cty.StringVal(string(tc)), }) } terraform-svchost-0.0.1/auth/token_credentials_test.go000066400000000000000000000012011436362640700232310ustar00rootroot00000000000000package auth import ( "net/http" "testing" "github.com/zclconf/go-cty/cty" ) func TestHostCredentialsToken(t *testing.T) { creds := HostCredentialsToken("foo-bar") { req := &http.Request{} creds.PrepareRequest(req) authStr := req.Header.Get("authorization") if got, want := authStr, "Bearer foo-bar"; got != want { t.Errorf("wrong Authorization header value %q; want %q", got, want) } } { got := creds.ToStore() want := cty.ObjectVal(map[string]cty.Value{ "token": cty.StringVal("foo-bar"), }) if !want.RawEquals(got) { t.Errorf("wrong storable object value\ngot: %#v\nwant: %#v", got, want) } } } terraform-svchost-0.0.1/disco/000077500000000000000000000000001436362640700163145ustar00rootroot00000000000000terraform-svchost-0.0.1/disco/disco.go000066400000000000000000000216041436362640700177470ustar00rootroot00000000000000// 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" "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 = defaultHttpTransport() // 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 } // ErrServiceDiscoveryNetworkRequest represents the error that occurs when // the service discovery fails for an unknown network problem. type ErrServiceDiscoveryNetworkRequest struct { err error } func (e ErrServiceDiscoveryNetworkRequest) Error() string { wrapped_error := fmt.Errorf("failed to request discovery document: %w", e.err) return wrapped_error.Error() } // 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, } } func (d *Disco) SetUserAgent(uaString string) { d.Transport = &userAgentRoundTripper{ innerRt: d.Transport, userAgent: uaString, } } // 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 } // CredentialsSource returns the credentials source associated with the receiver, // or an empty credentials source if none is associated. func (d *Disco) CredentialsSource() auth.CredentialsSource { if d.credsSrc == nil { // We'll return an empty one just to save the caller from having to // protect against the nil case, since this interface already allows // for the possibility of there being no credentials at all. return auth.StaticCredentialsSource(nil) } return d.credsSrc } // 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") 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, ErrServiceDiscoveryNetworkRequest{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) } terraform-svchost-0.0.1/disco/disco_test.go000066400000000000000000000257571436362640700210230ustar00rootroot00000000000000package disco import ( "crypto/tls" "net/http" "net/http/httptest" "net/url" "os" "strconv" "testing" "time" "github.com/hashicorp/terraform-svchost" "github.com/hashicorp/terraform-svchost/auth" ) func TestMain(m *testing.M) { // During all tests we override the HTTP transport we use for discovery // so it'll tolerate the locally-generated TLS certificates we use // for test URLs. httpTransport = &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } os.Exit(m.Run()) } func TestDiscover(t *testing.T) { t.Run("happy path", func(t *testing.T) { portStr, close := testServer(func(w http.ResponseWriter, r *http.Request) { resp := []byte(` { "thingy.v1": "http://example.com/foo", "wotsit.v2": "http://example.net/bar" } `) w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Length", strconv.Itoa(len(resp))) w.Write(resp) }) defer close() givenHost := "localhost" + portStr host, err := svchost.ForComparison(givenHost) if err != nil { t.Fatalf("test server hostname is invalid: %s", err) } d := New() discovered, err := d.Discover(host) if err != nil { t.Fatalf("unexpected discovery error: %s", err) } gotURL, err := discovered.ServiceURL("thingy.v1") if err != nil { t.Fatalf("unexpected service URL error: %s", err) } if gotURL == nil { t.Fatalf("found no URL for thingy.v1") } if got, want := gotURL.String(), "http://example.com/foo"; got != want { t.Fatalf("wrong result %q; want %q", got, want) } }) t.Run("chunked encoding", func(t *testing.T) { portStr, close := testServer(func(w http.ResponseWriter, r *http.Request) { resp := []byte(` { "thingy.v1": "http://example.com/foo", "wotsit.v2": "http://example.net/bar" } `) w.Header().Add("Content-Type", "application/json") // We're going to force chunked encoding here -- and thus prevent // the server from predicting the length -- so we can make sure // our client is tolerant of servers using this encoding. w.Write(resp[:5]) w.(http.Flusher).Flush() w.Write(resp[5:]) w.(http.Flusher).Flush() }) defer close() givenHost := "localhost" + portStr host, err := svchost.ForComparison(givenHost) if err != nil { t.Fatalf("test server hostname is invalid: %s", err) } d := New() discovered, err := d.Discover(host) if err != nil { t.Fatalf("unexpected discovery error: %s", err) } gotURL, err := discovered.ServiceURL("wotsit.v2") if err != nil { t.Fatalf("unexpected service URL error: %s", err) } if gotURL == nil { t.Fatalf("found no URL for wotsit.v2") } if got, want := gotURL.String(), "http://example.net/bar"; got != want { t.Fatalf("wrong result %q; want %q", got, want) } }) t.Run("with credentials", func(t *testing.T) { var authHeaderText string portStr, close := testServer(func(w http.ResponseWriter, r *http.Request) { resp := []byte(`{}`) authHeaderText = r.Header.Get("Authorization") w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Length", strconv.Itoa(len(resp))) w.Write(resp) }) defer close() givenHost := "localhost" + portStr host, err := svchost.ForComparison(givenHost) if err != nil { t.Fatalf("test server hostname is invalid: %s", err) } d := New() d.SetCredentialsSource(auth.StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{ host: map[string]interface{}{ "token": "abc123", }, })) d.Discover(host) if got, want := authHeaderText, "Bearer abc123"; got != want { t.Fatalf("wrong Authorization header\ngot: %s\nwant: %s", got, want) } }) t.Run("forced services override", func(t *testing.T) { forced := map[string]interface{}{ "thingy.v1": "http://example.net/foo", "wotsit.v2": "/foo", } d := New() d.ForceHostServices(svchost.Hostname("example.com"), forced) givenHost := "example.com" host, err := svchost.ForComparison(givenHost) if err != nil { t.Fatalf("test server hostname is invalid: %s", err) } discovered, err := d.Discover(host) if err != nil { t.Fatalf("unexpected discovery error: %s", err) } { gotURL, err := discovered.ServiceURL("thingy.v1") if err != nil { t.Fatalf("unexpected service URL error: %s", err) } if gotURL == nil { t.Fatalf("found no URL for thingy.v1") } if got, want := gotURL.String(), "http://example.net/foo"; got != want { t.Fatalf("wrong result %q; want %q", got, want) } } { gotURL, err := discovered.ServiceURL("wotsit.v2") if err != nil { t.Fatalf("unexpected service URL error: %s", err) } if gotURL == nil { t.Fatalf("found no URL for wotsit.v2") } if got, want := gotURL.String(), "https://example.com/foo"; got != want { t.Fatalf("wrong result %q; want %q", got, want) } } }) t.Run("not JSON", func(t *testing.T) { portStr, close := testServer(func(w http.ResponseWriter, r *http.Request) { resp := []byte(`{"thingy.v1": "http://example.com/foo"}`) w.Header().Add("Content-Type", "application/octet-stream") w.Write(resp) }) defer close() givenHost := "localhost" + portStr host, err := svchost.ForComparison(givenHost) if err != nil { t.Fatalf("test server hostname is invalid: %s", err) } d := New() discovered, err := d.Discover(host) if err == nil { t.Fatalf("expected a discovery error") } // Returned discovered should be nil. if discovered != nil { t.Errorf("discovered not nil; should be") } }) t.Run("malformed JSON", func(t *testing.T) { portStr, close := testServer(func(w http.ResponseWriter, r *http.Request) { resp := []byte(`{"thingy.v1": "htt`) // truncated, for example... w.Header().Add("Content-Type", "application/json") w.Write(resp) }) defer close() givenHost := "localhost" + portStr host, err := svchost.ForComparison(givenHost) if err != nil { t.Fatalf("test server hostname is invalid: %s", err) } d := New() discovered, err := d.Discover(host) if err == nil { t.Fatalf("expected a discovery error") } // Returned discovered should be nil. if discovered != nil { t.Errorf("discovered not nil; should be") } }) t.Run("JSON with redundant charset", func(t *testing.T) { // The JSON RFC defines no parameters for the application/json // MIME type, but some servers have a weird tendency to just add // "charset" to everything, so we'll make sure we ignore it successfully. // (JSON uses content sniffing for encoding detection, not media type params.) portStr, close := testServer(func(w http.ResponseWriter, r *http.Request) { resp := []byte(`{"thingy.v1": "http://example.com/foo"}`) w.Header().Add("Content-Type", "application/json; charset=latin-1") w.Write(resp) }) defer close() givenHost := "localhost" + portStr host, err := svchost.ForComparison(givenHost) if err != nil { t.Fatalf("test server hostname is invalid: %s", err) } d := New() discovered, err := d.Discover(host) if err != nil { t.Fatalf("unexpected discovery error: %s", err) } if discovered.services == nil { t.Errorf("response is empty; shouldn't be") } }) t.Run("no discovery doc", func(t *testing.T) { portStr, close := testServer(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(404) }) defer close() givenHost := "localhost" + portStr host, err := svchost.ForComparison(givenHost) if err != nil { t.Fatalf("test server hostname is invalid: %s", err) } d := New() discovered, err := d.Discover(host) if err != nil { t.Fatalf("unexpected discovery error: %s", err) } // Returned discovered.services should be nil (empty). if discovered.services != nil { t.Errorf("discovered.services not nil (empty); should be") } }) t.Run("discovery error", func(t *testing.T) { // Make a channel and then ignore messages to simulate a Client.Timeout donec := make(chan bool, 1) portStr, close := testServer(func(w http.ResponseWriter, r *http.Request) { <-donec }) defer close() defer func() { donec <- true }() givenHost := "localhost" + portStr host, err := svchost.ForComparison(givenHost) if err != nil { t.Fatalf("test server hostname is invalid: %s", err) } d := New() transport := d.Transport.(*http.Transport) origTimeout := transport.ResponseHeaderTimeout transport.ResponseHeaderTimeout = 10 * time.Millisecond defer func() { transport.ResponseHeaderTimeout = origTimeout }() discovered, err := d.Discover(host) // Verify the error is an ErrServiceDiscoveryNetworkRequest _, isDiscoError := err.(ErrServiceDiscoveryNetworkRequest) if !isDiscoError { t.Fatalf("was not an ErrServiceDiscoveryNetworkRequest, got %T %v", err, err) } // Returned discovered should be nil (empty). if discovered != nil { t.Errorf("discovered not nil (empty); should be") } }) t.Run("redirect", func(t *testing.T) { // For this test, we have two servers and one redirects to the other portStr1, close1 := testServer(func(w http.ResponseWriter, r *http.Request) { // This server is the one that returns a real response. resp := []byte(`{"thingy.v1": "http://example.com/foo"}`) w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Length", strconv.Itoa(len(resp))) w.Write(resp) }) portStr2, close2 := testServer(func(w http.ResponseWriter, r *http.Request) { // This server is the one that redirects. http.Redirect(w, r, "https://127.0.0.1"+portStr1+"/.well-known/terraform.json", 302) }) defer close1() defer close2() givenHost := "localhost" + portStr2 host, err := svchost.ForComparison(givenHost) if err != nil { t.Fatalf("test server hostname is invalid: %s", err) } d := New() discovered, err := d.Discover(host) if err != nil { t.Fatalf("unexpected discovery error: %s", err) } gotURL, err := discovered.ServiceURL("thingy.v1") if err != nil { t.Fatalf("unexpected service URL error: %s", err) } if gotURL == nil { t.Fatalf("found no URL for thingy.v1") } if got, want := gotURL.String(), "http://example.com/foo"; got != want { t.Fatalf("wrong result %q; want %q", got, want) } // The base URL for the host object should be the URL we redirected to, // rather than the we redirected _from_. gotBaseURL := discovered.discoURL.String() wantBaseURL := "https://127.0.0.1" + portStr1 + "/.well-known/terraform.json" if gotBaseURL != wantBaseURL { t.Errorf("incorrect base url %s; want %s", gotBaseURL, wantBaseURL) } }) } func testServer(h func(w http.ResponseWriter, r *http.Request)) (portStr string, close func()) { server := httptest.NewTLSServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { // Test server always returns 404 if the URL isn't what we expect if r.URL.Path != "/.well-known/terraform.json" { w.WriteHeader(404) w.Write([]byte("not found")) return } // If the URL is correct then the given hander decides the response h(w, r) }, )) serverURL, _ := url.Parse(server.URL) portStr = serverURL.Port() if portStr != "" { portStr = ":" + portStr } close = func() { server.Close() } return portStr, close } terraform-svchost-0.0.1/disco/host.go000066400000000000000000000304201436362640700176170ustar00rootroot00000000000000package disco import ( "encoding/json" "fmt" "log" "net/http" "net/url" "os" "strconv" "strings" "time" "github.com/hashicorp/go-version" ) const versionServiceID = "versions.v1" // Host represents a service discovered host. type Host struct { discoURL *url.URL hostname string services map[string]interface{} transport http.RoundTripper } // Constraints represents the version constraints of a service. type Constraints struct { Service string `json:"service"` Product string `json:"product"` Minimum string `json:"minimum"` Maximum string `json:"maximum"` Excluding []string `json:"excluding"` } // ErrServiceNotProvided is returned when the service is not provided. type ErrServiceNotProvided struct { hostname string service string } // Error returns a customized error message. func (e *ErrServiceNotProvided) Error() string { if e.hostname == "" { return fmt.Sprintf("host does not provide a %s service", e.service) } return fmt.Sprintf("host %s does not provide a %s service", e.hostname, e.service) } // ErrVersionNotSupported is returned when the version is not supported. type ErrVersionNotSupported struct { hostname string service string version string } // Error returns a customized error message. func (e *ErrVersionNotSupported) Error() string { if e.hostname == "" { return fmt.Sprintf("host does not support %s version %s", e.service, e.version) } return fmt.Sprintf("host %s does not support %s version %s", e.hostname, e.service, e.version) } // ErrNoVersionConstraints is returned when checkpoint was disabled // or the endpoint to query for version constraints was unavailable. type ErrNoVersionConstraints struct { disabled bool } // Error returns a customized error message. func (e *ErrNoVersionConstraints) Error() string { if e.disabled { return "checkpoint disabled" } return "unable to contact versions service" } // ServiceURL returns the URL associated with the given service identifier, // which should be of the form "servicename.vN". // // A non-nil result is always an absolute URL with a scheme of either HTTPS // or HTTP. func (h *Host) ServiceURL(id string) (*url.URL, error) { svc, ver, err := parseServiceID(id) if err != nil { return nil, err } // No services supported for an empty Host. if h == nil || h.services == nil { return nil, &ErrServiceNotProvided{service: svc} } urlStr, ok := h.services[id].(string) if !ok { // See if we have a matching service as that would indicate // the service is supported, but not the requested version. for serviceID := range h.services { if strings.HasPrefix(serviceID, svc+".") { return nil, &ErrVersionNotSupported{ hostname: h.hostname, service: svc, version: ver.Original(), } } } // No discovered services match the requested service. return nil, &ErrServiceNotProvided{hostname: h.hostname, service: svc} } u, err := h.parseURL(urlStr) if err != nil { return nil, fmt.Errorf("Failed to parse service URL: %v", err) } return u, nil } // ServiceOAuthClient returns the OAuth client configuration associated with the // given service identifier, which should be of the form "servicename.vN". // // This is an alternative to ServiceURL for unusual services that require // a full OAuth2 client definition rather than just a URL. Use this only // for services whose specification calls for this sort of definition. func (h *Host) ServiceOAuthClient(id string) (*OAuthClient, error) { svc, ver, err := parseServiceID(id) if err != nil { return nil, err } // No services supported for an empty Host. if h == nil || h.services == nil { return nil, &ErrServiceNotProvided{service: svc} } if _, ok := h.services[id]; !ok { // See if we have a matching service as that would indicate // the service is supported, but not the requested version. for serviceID := range h.services { if strings.HasPrefix(serviceID, svc+".") { return nil, &ErrVersionNotSupported{ hostname: h.hostname, service: svc, version: ver.Original(), } } } // No discovered services match the requested service. return nil, &ErrServiceNotProvided{hostname: h.hostname, service: svc} } var raw map[string]interface{} switch v := h.services[id].(type) { case map[string]interface{}: raw = v // Great! case []map[string]interface{}: // An absolutely infuriating legacy HCL ambiguity. raw = v[0] default: // Debug message because raw Go types don't belong in our UI. log.Printf("[DEBUG] The definition for %s has Go type %T", id, h.services[id]) return nil, fmt.Errorf("Service %s must be declared with an object value in the service discovery document", id) } var grantTypes OAuthGrantTypeSet if rawGTs, ok := raw["grant_types"]; ok { if gts, ok := rawGTs.([]interface{}); ok { var kws []string for _, gtI := range gts { gt, ok := gtI.(string) if !ok { // We'll ignore this so that we can potentially introduce // other types into this array later if we need to. continue } kws = append(kws, gt) } grantTypes = NewOAuthGrantTypeSet(kws...) } else { return nil, fmt.Errorf("Service %s is defined with invalid grant_types property: must be an array of grant type strings", id) } } else { grantTypes = NewOAuthGrantTypeSet("authz_code") } ret := &OAuthClient{ SupportedGrantTypes: grantTypes, } if clientIDStr, ok := raw["client"].(string); ok { ret.ID = clientIDStr } else { return nil, fmt.Errorf("Service %s definition is missing required property \"client\"", id) } if urlStr, ok := raw["authz"].(string); ok { u, err := h.parseURL(urlStr) if err != nil { return nil, fmt.Errorf("Failed to parse authorization URL: %v", err) } ret.AuthorizationURL = u } else { if grantTypes.RequiresAuthorizationEndpoint() { return nil, fmt.Errorf("Service %s definition is missing required property \"authz\"", id) } } if urlStr, ok := raw["token"].(string); ok { u, err := h.parseURL(urlStr) if err != nil { return nil, fmt.Errorf("Failed to parse token URL: %v", err) } ret.TokenURL = u } else { if grantTypes.RequiresTokenEndpoint() { return nil, fmt.Errorf("Service %s definition is missing required property \"token\"", id) } } if portsRaw, ok := raw["ports"].([]interface{}); ok { if len(portsRaw) != 2 { return nil, fmt.Errorf("Invalid \"ports\" definition for service %s: must be a two-element array", id) } invalidPortsErr := fmt.Errorf("Invalid \"ports\" definition for service %s: both ports must be whole numbers between 1024 and 65535", id) ports := make([]uint16, 2) for i := range ports { switch v := portsRaw[i].(type) { case float64: // JSON unmarshaling always produces float64. HCL 2 might, if // an invalid fractional number were given. if float64(uint16(v)) != v || v < 1024 { return nil, invalidPortsErr } ports[i] = uint16(v) case int: // Legacy HCL produces int. HCL 2 will too, if the given number // is a whole number. if v < 1024 || v > 65535 { return nil, invalidPortsErr } ports[i] = uint16(v) default: // Debug message because raw Go types don't belong in our UI. log.Printf("[DEBUG] Port value %d has Go type %T", i, portsRaw[i]) return nil, invalidPortsErr } } if ports[1] < ports[0] { return nil, fmt.Errorf("Invalid \"ports\" definition for service %s: minimum port cannot be greater than maximum port", id) } ret.MinPort = ports[0] ret.MaxPort = ports[1] } else { // Default is to accept any port in the range, for a client that is // able to call back to any localhost port. ret.MinPort = 1024 ret.MaxPort = 65535 } if scopesRaw, ok := raw["scopes"].([]interface{}); ok { var scopes []string for _, scopeI := range scopesRaw { scope, ok := scopeI.(string) if !ok { return nil, fmt.Errorf("Invalid \"scopes\" for service %s: all scopes must be strings", id) } scopes = append(scopes, scope) } ret.Scopes = scopes } return ret, nil } func (h *Host) parseURL(urlStr string) (*url.URL, error) { u, err := url.Parse(urlStr) if err != nil { return nil, err } // Make relative URLs absolute using our discovery URL. if !u.IsAbs() { u = h.discoURL.ResolveReference(u) } if u.Scheme != "https" && u.Scheme != "http" { return nil, fmt.Errorf("unsupported scheme %s", u.Scheme) } if u.User != nil { return nil, fmt.Errorf("embedded username/password information is not permitted") } // Fragment part is irrelevant, since we're not a browser. u.Fragment = "" return u, nil } // VersionConstraints returns the contraints for a given service identifier // (which should be of the form "servicename.vN") and product. // // When an exact (service and version) match is found, the constraints for // that service are returned. // // When the requested version is not provided but the service is, we will // search for all alternative versions. If mutliple alternative versions // are found, the contrains of the latest available version are returned. // // When a service is not provided at all an error will be returned instead. // // When checkpoint is disabled or when a 404 is returned after making the // HTTP call, an ErrNoVersionConstraints error will be returned. func (h *Host) VersionConstraints(id, product string) (*Constraints, error) { svc, _, err := parseServiceID(id) if err != nil { return nil, err } // Return early if checkpoint is disabled. if disabled := os.Getenv("CHECKPOINT_DISABLE"); disabled != "" { return nil, &ErrNoVersionConstraints{disabled: true} } // No services supported for an empty Host. if h == nil || h.services == nil { return nil, &ErrServiceNotProvided{service: svc} } // Try to get the service URL for the version service and // return early if the service isn't provided by the host. u, err := h.ServiceURL(versionServiceID) if err != nil { return nil, err } // Check if we have an exact (service and version) match. if _, ok := h.services[id].(string); !ok { // If we don't have an exact match, we search for all matching // services and then use the service ID of the latest version. var services []string for serviceID := range h.services { if strings.HasPrefix(serviceID, svc+".") { services = append(services, serviceID) } } if len(services) == 0 { // No discovered services match the requested service. return nil, &ErrServiceNotProvided{hostname: h.hostname, service: svc} } // Set id to the latest service ID we found. var latest *version.Version for _, serviceID := range services { if _, ver, err := parseServiceID(serviceID); err == nil { if latest == nil || latest.LessThan(ver) { id = serviceID latest = ver } } } } // Set a default timeout of 1 sec for the versions request (in milliseconds) timeout := 1000 if v, err := strconv.Atoi(os.Getenv("CHECKPOINT_TIMEOUT")); err == nil { timeout = v } client := &http.Client{ Transport: h.transport, Timeout: time.Duration(timeout) * time.Millisecond, } // Prepare the service URL by setting the service and product. v := u.Query() v.Set("product", product) u.Path += id u.RawQuery = v.Encode() // Create a new request. req, err := http.NewRequest("GET", u.String(), nil) if err != nil { return nil, fmt.Errorf("Failed to create version constraints request: %v", err) } req.Header.Set("Accept", "application/json") log.Printf("[DEBUG] Retrieve version constraints for service %s and product %s", id, product) resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("Failed to request version constraints: %v", err) } defer resp.Body.Close() if resp.StatusCode == 404 { return nil, &ErrNoVersionConstraints{disabled: false} } if resp.StatusCode != 200 { return nil, fmt.Errorf("Failed to request version constraints: %s", resp.Status) } // Parse the constraints from the response body. result := &Constraints{} if err := json.NewDecoder(resp.Body).Decode(result); err != nil { return nil, fmt.Errorf("Error parsing version constraints: %v", err) } return result, nil } func parseServiceID(id string) (string, *version.Version, error) { parts := strings.SplitN(id, ".", 2) if len(parts) != 2 { return "", nil, fmt.Errorf("Invalid service ID format (i.e. service.vN): %s", id) } version, err := version.NewVersion(parts[1]) if err != nil { return "", nil, fmt.Errorf("Invalid service version: %v", err) } return parts[0], version, nil } terraform-svchost-0.0.1/disco/host_test.go000066400000000000000000000371371436362640700206720ustar00rootroot00000000000000package disco import ( "fmt" "net/http" "net/http/httptest" "net/url" "os" "path" "reflect" "strconv" "strings" "testing" "github.com/google/go-cmp/cmp" ) func TestHostServiceURL(t *testing.T) { baseURL, _ := url.Parse("https://example.com/disco/foo.json") host := Host{ discoURL: baseURL, hostname: "test-server", services: map[string]interface{}{ "absolute.v1": "http://example.net/foo/bar", "absolutewithport.v1": "http://example.net:8080/foo/bar", "relative.v1": "./stu/", "rootrelative.v1": "/baz", "protorelative.v1": "//example.net/", "withfragment.v1": "http://example.org/#foo", "querystring.v1": "https://example.net/baz?foo=bar", "nothttp.v1": "ftp://127.0.0.1/pub/", "invalid.v1": "***not A URL at all!:/<@@@@>***", }, } tests := []struct { ID string want string err string }{ {"absolute.v1", "http://example.net/foo/bar", ""}, {"absolutewithport.v1", "http://example.net:8080/foo/bar", ""}, {"relative.v1", "https://example.com/disco/stu/", ""}, {"rootrelative.v1", "https://example.com/baz", ""}, {"protorelative.v1", "https://example.net/", ""}, {"withfragment.v1", "http://example.org/", ""}, {"querystring.v1", "https://example.net/baz?foo=bar", ""}, {"nothttp.v1", "", "unsupported scheme"}, {"invalid.v1", "", "Failed to parse service URL"}, } for _, test := range tests { t.Run(test.ID, func(t *testing.T) { url, err := host.ServiceURL(test.ID) if (err != nil || test.err != "") && (err == nil || !strings.Contains(err.Error(), test.err)) { t.Fatalf("unexpected service URL error: %s", err) } var got string if url != nil { got = url.String() } else { got = "" } if got != test.want { t.Errorf("wrong result\ngot: %s\nwant: %s", got, test.want) } }) } } func TestHostServiceOAuthClient(t *testing.T) { baseURL, _ := url.Parse("https://example.com/disco/foo.json") host := Host{ discoURL: baseURL, hostname: "test-server", services: map[string]interface{}{ "explicitgranttype.v1": map[string]interface{}{ "client": "explicitgranttype", "authz": "./authz", "token": "./token", "grant_types": []interface{}{"authz_code", "password", "tbd"}, }, "customports.v1": map[string]interface{}{ "client": "customports", "authz": "./authz", "token": "./token", "ports": []interface{}{1025, 1026}, }, "invalidports.v1": map[string]interface{}{ "client": "invalidports", "authz": "./authz", "token": "./token", "ports": []interface{}{1, 65535}, }, "missingauthz.v1": map[string]interface{}{ "client": "missingauthz", "token": "./token", }, "missingtoken.v1": map[string]interface{}{ "client": "missingtoken", "authz": "./authz", }, "passwordmissingauthz.v1": map[string]interface{}{ "client": "passwordmissingauthz", "token": "./token", "grant_types": []interface{}{"password"}, }, "absolute.v1": map[string]interface{}{ "client": "absolute", "authz": "http://example.net/foo/authz", "token": "http://example.net/foo/token", }, "absolutewithport.v1": map[string]interface{}{ "client": "absolutewithport", "authz": "http://example.net:8000/foo/authz", "token": "http://example.net:8000/foo/token", }, "relative.v1": map[string]interface{}{ "client": "relative", "authz": "./authz", "token": "./token", }, "rootrelative.v1": map[string]interface{}{ "client": "rootrelative", "authz": "/authz", "token": "/token", }, "protorelative.v1": map[string]interface{}{ "client": "protorelative", "authz": "//example.net/authz", "token": "//example.net/token", }, "nothttp.v1": map[string]interface{}{ "client": "nothttp", "authz": "ftp://127.0.0.1/pub/authz", "token": "ftp://127.0.0.1/pub/token", }, "invalidauthz.v1": map[string]interface{}{ "client": "invalidauthz", "authz": "***not A URL at all!:/<@@@@>***", "token": "/foo", }, "invalidtoken.v1": map[string]interface{}{ "client": "invalidauthz", "authz": "/foo", "token": "***not A URL at all!:/<@@@@>***", }, "scopesincluded.v1": map[string]interface{}{ "client": "scopesincluded", "authz": "/auth", "token": "/token", "scopes": []interface{}{"app1.full_access", "app2.read_only"}, }, "scopesempty.v1": map[string]interface{}{ "client": "scopesempty", "authz": "/auth", "token": "/token", "scopes": []interface{}{}, }, "scopesbad.v1": map[string]interface{}{ "client": "scopesbad", "authz": "/auth", "token": "/token", "scopes": []interface{}{"app1.full_access", 42}, }, }, } mustURL := func(t *testing.T, s string) *url.URL { t.Helper() u, err := url.Parse(s) if err != nil { t.Fatalf("invalid wanted URL %s in test case: %s", s, err) } return u } tests := []struct { ID string want *OAuthClient err string }{ { "explicitgranttype.v1", &OAuthClient{ ID: "explicitgranttype", AuthorizationURL: mustURL(t, "https://example.com/disco/authz"), TokenURL: mustURL(t, "https://example.com/disco/token"), MinPort: 1024, MaxPort: 65535, SupportedGrantTypes: NewOAuthGrantTypeSet("authz_code", "password", "tbd"), }, "", }, { "customports.v1", &OAuthClient{ ID: "customports", AuthorizationURL: mustURL(t, "https://example.com/disco/authz"), TokenURL: mustURL(t, "https://example.com/disco/token"), MinPort: 1025, MaxPort: 1026, SupportedGrantTypes: NewOAuthGrantTypeSet("authz_code"), }, "", }, { "invalidports.v1", nil, `Invalid "ports" definition for service invalidports.v1: both ports must be whole numbers between 1024 and 65535`, }, { "missingauthz.v1", nil, `Service missingauthz.v1 definition is missing required property "authz"`, }, { "missingtoken.v1", nil, `Service missingtoken.v1 definition is missing required property "token"`, }, { "passwordmissingauthz.v1", &OAuthClient{ ID: "passwordmissingauthz", TokenURL: mustURL(t, "https://example.com/disco/token"), MinPort: 1024, MaxPort: 65535, SupportedGrantTypes: NewOAuthGrantTypeSet("password"), }, "", }, { "absolute.v1", &OAuthClient{ ID: "absolute", AuthorizationURL: mustURL(t, "http://example.net/foo/authz"), TokenURL: mustURL(t, "http://example.net/foo/token"), MinPort: 1024, MaxPort: 65535, SupportedGrantTypes: NewOAuthGrantTypeSet("authz_code"), }, "", }, { "absolutewithport.v1", &OAuthClient{ ID: "absolutewithport", AuthorizationURL: mustURL(t, "http://example.net:8000/foo/authz"), TokenURL: mustURL(t, "http://example.net:8000/foo/token"), MinPort: 1024, MaxPort: 65535, SupportedGrantTypes: NewOAuthGrantTypeSet("authz_code"), }, "", }, { "relative.v1", &OAuthClient{ ID: "relative", AuthorizationURL: mustURL(t, "https://example.com/disco/authz"), TokenURL: mustURL(t, "https://example.com/disco/token"), MinPort: 1024, MaxPort: 65535, SupportedGrantTypes: NewOAuthGrantTypeSet("authz_code"), }, "", }, { "rootrelative.v1", &OAuthClient{ ID: "rootrelative", AuthorizationURL: mustURL(t, "https://example.com/authz"), TokenURL: mustURL(t, "https://example.com/token"), MinPort: 1024, MaxPort: 65535, SupportedGrantTypes: NewOAuthGrantTypeSet("authz_code"), }, "", }, { "protorelative.v1", &OAuthClient{ ID: "protorelative", AuthorizationURL: mustURL(t, "https://example.net/authz"), TokenURL: mustURL(t, "https://example.net/token"), MinPort: 1024, MaxPort: 65535, SupportedGrantTypes: NewOAuthGrantTypeSet("authz_code"), }, "", }, { "nothttp.v1", nil, "Failed to parse authorization URL: unsupported scheme ftp", }, { "invalidauthz.v1", nil, "Failed to parse authorization URL: parse \"***not A URL at all!:/<@@@@>***\": first path segment in URL cannot contain colon", }, { "invalidtoken.v1", nil, "Failed to parse token URL: parse \"***not A URL at all!:/<@@@@>***\": first path segment in URL cannot contain colon", }, { "scopesincluded.v1", &OAuthClient{ ID: "scopesincluded", AuthorizationURL: mustURL(t, "https://example.com/auth"), TokenURL: mustURL(t, "https://example.com/token"), MinPort: 1024, MaxPort: 65535, SupportedGrantTypes: NewOAuthGrantTypeSet("authz_code"), Scopes: []string{"app1.full_access", "app2.read_only"}, }, "", }, { "scopesempty.v1", &OAuthClient{ ID: "scopesempty", AuthorizationURL: mustURL(t, "https://example.com/auth"), TokenURL: mustURL(t, "https://example.com/token"), MinPort: 1024, MaxPort: 65535, SupportedGrantTypes: NewOAuthGrantTypeSet("authz_code"), }, "", }, { "scopesbad.v1", nil, `Invalid "scopes" for service scopesbad.v1: all scopes must be strings`, }, } for _, test := range tests { t.Run(test.ID, func(t *testing.T) { got, err := host.ServiceOAuthClient(test.ID) if (err != nil || test.err != "") && (err == nil || !strings.Contains(err.Error(), test.err)) { t.Fatalf("unexpected service URL error: %s", err) } if diff := cmp.Diff(test.want, got); diff != "" { t.Errorf("wrong result\n%s", diff) } }) } } func TestVersionConstrains(t *testing.T) { baseURL, _ := url.Parse("https://example.com/disco/foo.json") t.Run("exact service version is provided", func(t *testing.T) { portStr, close := testVersionsServer(func(w http.ResponseWriter, r *http.Request) { resp := []byte(` { "service": "%s", "product": "%s", "minimum": "0.11.8", "maximum": "0.12.0" }`) // Add the requested service and product to the response. service := path.Base(r.URL.Path) product := r.URL.Query().Get("product") resp = []byte(fmt.Sprintf(string(resp), service, product)) w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Length", strconv.Itoa(len(resp))) w.Write(resp) }) defer close() host := Host{ discoURL: baseURL, hostname: "test-server", transport: httpTransport, services: map[string]interface{}{ "thingy.v1": "/api/v1/", "thingy.v2": "/api/v2/", "versions.v1": "https://localhost" + portStr + "/v1/versions/", }, } expected := &Constraints{ Service: "thingy.v1", Product: "terraform", Minimum: "0.11.8", Maximum: "0.12.0", } actual, err := host.VersionConstraints("thingy.v1", "terraform") if err != nil { t.Fatalf("unexpected version constraints error: %s", err) } if !reflect.DeepEqual(actual, expected) { t.Fatalf("expected %#v, got: %#v", expected, actual) } }) t.Run("service provided with different versions", func(t *testing.T) { portStr, close := testVersionsServer(func(w http.ResponseWriter, r *http.Request) { resp := []byte(` { "service": "%s", "product": "%s", "minimum": "0.11.8", "maximum": "0.12.0" }`) // Add the requested service and product to the response. service := path.Base(r.URL.Path) product := r.URL.Query().Get("product") resp = []byte(fmt.Sprintf(string(resp), service, product)) w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Length", strconv.Itoa(len(resp))) w.Write(resp) }) defer close() host := Host{ discoURL: baseURL, hostname: "test-server", transport: httpTransport, services: map[string]interface{}{ "thingy.v2": "/api/v2/", "thingy.v3": "/api/v3/", "versions.v1": "https://localhost" + portStr + "/v1/versions/", }, } expected := &Constraints{ Service: "thingy.v3", Product: "terraform", Minimum: "0.11.8", Maximum: "0.12.0", } actual, err := host.VersionConstraints("thingy.v1", "terraform") if err != nil { t.Fatalf("unexpected version constraints error: %s", err) } if !reflect.DeepEqual(actual, expected) { t.Fatalf("expected %#v, got: %#v", expected, actual) } }) t.Run("service not provided", func(t *testing.T) { host := Host{ discoURL: baseURL, hostname: "test-server", transport: httpTransport, services: map[string]interface{}{ "versions.v1": "https://localhost/v1/versions/", }, } _, err := host.VersionConstraints("thingy.v1", "terraform") if _, ok := err.(*ErrServiceNotProvided); !ok { t.Fatalf("expected service not provided error, got: %v", err) } }) t.Run("versions service returns a 404", func(t *testing.T) { portStr, close := testVersionsServer(nil) defer close() host := Host{ discoURL: baseURL, hostname: "test-server", transport: httpTransport, services: map[string]interface{}{ "thingy.v1": "/api/v1/", "versions.v1": "https://localhost" + portStr + "/v1/non-existent/", }, } _, err := host.VersionConstraints("thingy.v1", "terraform") if _, ok := err.(*ErrNoVersionConstraints); !ok { t.Fatalf("expected service not provided error, got: %v", err) } }) t.Run("checkpoint is disabled", func(t *testing.T) { if err := os.Setenv("CHECKPOINT_DISABLE", "1"); err != nil { t.Fatalf("unexpected error: %v", err) } defer os.Unsetenv("CHECKPOINT_DISABLE") host := Host{ discoURL: baseURL, hostname: "test-server", transport: httpTransport, services: map[string]interface{}{ "thingy.v1": "/api/v1/", "versions.v1": "https://localhost/v1/versions/", }, } _, err := host.VersionConstraints("thingy.v1", "terraform") if _, ok := err.(*ErrNoVersionConstraints); !ok { t.Fatalf("expected service not provided error, got: %v", err) } }) t.Run("versions service not discovered", func(t *testing.T) { host := Host{ discoURL: baseURL, hostname: "test-server", transport: httpTransport, services: map[string]interface{}{ "thingy.v1": "/api/v1/", }, } _, err := host.VersionConstraints("thingy.v1", "terraform") if _, ok := err.(*ErrServiceNotProvided); !ok { t.Fatalf("expected service not provided error, got: %v", err) } }) t.Run("versions service version not discovered", func(t *testing.T) { host := Host{ discoURL: baseURL, hostname: "test-server", transport: httpTransport, services: map[string]interface{}{ "thingy.v1": "/api/v1/", "versions.v2": "https://localhost/v2/versions/", }, } _, err := host.VersionConstraints("thingy.v1", "terraform") if _, ok := err.(*ErrVersionNotSupported); !ok { t.Fatalf("expected service not provided error, got: %v", err) } }) } func testVersionsServer(h func(w http.ResponseWriter, r *http.Request)) (portStr string, close func()) { server := httptest.NewTLSServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { // Test server always returns 404 if the URL isn't what we expect if !strings.HasPrefix(r.URL.Path, "/v1/versions/") { w.WriteHeader(404) w.Write([]byte("not found")) return } // If the URL is correct then the given hander decides the response h(w, r) }, )) serverURL, _ := url.Parse(server.URL) portStr = serverURL.Port() if portStr != "" { portStr = ":" + portStr } close = func() { server.Close() } return portStr, close } terraform-svchost-0.0.1/disco/http_transport.go000066400000000000000000000011351436362640700217360ustar00rootroot00000000000000package disco import ( "net/http" "github.com/hashicorp/go-cleanhttp" ) const DefaultUserAgent = "terraform-svchost/1.0" func defaultHttpTransport() http.RoundTripper { t := cleanhttp.DefaultPooledTransport() return &userAgentRoundTripper{ innerRt: t, userAgent: DefaultUserAgent, } } type userAgentRoundTripper struct { innerRt http.RoundTripper userAgent string } func (rt *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { if _, ok := req.Header["User-Agent"]; !ok { req.Header.Set("User-Agent", rt.userAgent) } return rt.innerRt.RoundTrip(req) } terraform-svchost-0.0.1/disco/oauth_client.go000066400000000000000000000135421436362640700213260ustar00rootroot00000000000000package disco import ( "fmt" "net/url" "strings" "golang.org/x/oauth2" ) // OAuthClient represents an OAuth client configuration, which is used for // unusual services that require an entire OAuth client configuration as part // of their service discovery, rather than just a URL. type OAuthClient struct { // ID is the identifier for the client, to be used as "client_id" in // OAuth requests. ID string // Authorization URL is the URL of the authorization endpoint that must // be used for this OAuth client, as defined in the OAuth2 specifications. // // Not all grant types use the authorization endpoint, so it may be omitted // if none of the grant types in SupportedGrantTypes require it. AuthorizationURL *url.URL // Token URL is the URL of the token endpoint that must be used for this // OAuth client, as defined in the OAuth2 specifications. // // Not all grant types use the token endpoint, so it may be omitted // if none of the grant types in SupportedGrantTypes require it. TokenURL *url.URL // MinPort and MaxPort define a range of TCP ports on localhost that this // client is able to use as redirect_uri in an authorization request. // Terraform will select a port from this range for the temporary HTTP // server it creates to receive the authorization response, giving // a URL like http://localhost:NNN/ where NNN is the selected port number. // // Terraform will reject any port numbers in this range less than 1024, // to respect the common convention (enforced on some operating systems) // that lower port numbers are reserved for "privileged" services. MinPort, MaxPort uint16 // SupportedGrantTypes is a set of the grant types that the client may // choose from. This includes an entry for each distinct type advertised // by the server, even if a particular keyword is not supported by the // current version of Terraform. SupportedGrantTypes OAuthGrantTypeSet // Oauth2 does not require scopes for the authorization endpoint, however // OIDC does. Optional list of scopes to include in auth code and token // requests. Scopes []string } // Endpoint returns an oauth2.Endpoint value ready to be used with the oauth2 // library, representing the URLs from the receiver. func (c *OAuthClient) Endpoint() oauth2.Endpoint { ep := oauth2.Endpoint{ // We don't actually auth because we're not a server-based OAuth client, // so this instead just means that we include client_id as an argument // in our requests. AuthStyle: oauth2.AuthStyleInParams, } if c.AuthorizationURL != nil { ep.AuthURL = c.AuthorizationURL.String() } if c.TokenURL != nil { ep.TokenURL = c.TokenURL.String() } return ep } // OAuthGrantType is an enumeration of grant type strings that a host can // advertise support for. // // Values of this type don't necessarily match with a known constant of the // type, because they may represent grant type keywords defined in a later // version of Terraform which this version doesn't yet know about. type OAuthGrantType string const ( // OAuthAuthzCodeGrant represents an authorization code grant, as // defined in IETF RFC 6749 section 4.1. OAuthAuthzCodeGrant = OAuthGrantType("authz_code") // OAuthOwnerPasswordGrant represents a resource owner password // credentials grant, as defined in IETF RFC 6749 section 4.3. OAuthOwnerPasswordGrant = OAuthGrantType("password") ) // UsesAuthorizationEndpoint returns true if the receiving grant type makes // use of the authorization endpoint from the client configuration, and thus // if the authorization endpoint ought to be required. func (t OAuthGrantType) UsesAuthorizationEndpoint() bool { switch t { case OAuthAuthzCodeGrant: return true case OAuthOwnerPasswordGrant: return false default: // We'll default to false so that we don't impose any requirements // on any grant type keywords that might be defined for future // versions of Terraform. return false } } // UsesTokenEndpoint returns true if the receiving grant type makes // use of the token endpoint from the client configuration, and thus // if the authorization endpoint ought to be required. func (t OAuthGrantType) UsesTokenEndpoint() bool { switch t { case OAuthAuthzCodeGrant: return true case OAuthOwnerPasswordGrant: return true default: // We'll default to false so that we don't impose any requirements // on any grant type keywords that might be defined for future // versions of Terraform. return false } } // OAuthGrantTypeSet represents a set of OAuthGrantType values. type OAuthGrantTypeSet map[OAuthGrantType]struct{} // NewOAuthGrantTypeSet constructs a new grant type set from the given list // of grant type keyword strings. Any duplicates in the list are ignored. func NewOAuthGrantTypeSet(keywords ...string) OAuthGrantTypeSet { ret := make(OAuthGrantTypeSet, len(keywords)) for _, kw := range keywords { ret[OAuthGrantType(kw)] = struct{}{} } return ret } // Has returns true if the given grant type is in the receiving set. func (s OAuthGrantTypeSet) Has(t OAuthGrantType) bool { _, ok := s[t] return ok } // RequiresAuthorizationEndpoint returns true if any of the grant types in // the set are known to require an authorization endpoint. func (s OAuthGrantTypeSet) RequiresAuthorizationEndpoint() bool { for t := range s { if t.UsesAuthorizationEndpoint() { return true } } return false } // RequiresTokenEndpoint returns true if any of the grant types in // the set are known to require a token endpoint. func (s OAuthGrantTypeSet) RequiresTokenEndpoint() bool { for t := range s { if t.UsesTokenEndpoint() { return true } } return false } // GoString implements fmt.GoStringer. func (s OAuthGrantTypeSet) GoString() string { var buf strings.Builder i := 0 buf.WriteString("disco.NewOAuthGrantTypeSet(") for t := range s { if i > 0 { buf.WriteString(", ") } fmt.Fprintf(&buf, "%q", string(t)) i++ } buf.WriteString(")") return buf.String() } terraform-svchost-0.0.1/go.mod000066400000000000000000000007211436362640700163210ustar00rootroot00000000000000module github.com/hashicorp/terraform-svchost go 1.19 require ( github.com/google/go-cmp v0.5.9 github.com/hashicorp/go-cleanhttp v0.5.2 github.com/hashicorp/go-version v1.6.0 github.com/zclconf/go-cty v1.12.1 golang.org/x/net v0.5.0 golang.org/x/oauth2 v0.4.0 ) require ( github.com/golang/protobuf v1.5.2 // indirect golang.org/x/text v0.6.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.28.1 // indirect ) terraform-svchost-0.0.1/go.sum000066400000000000000000000054241436362640700163530ustar00rootroot00000000000000github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/zclconf/go-cty v1.12.1 h1:PcupnljUm9EIvbgSHQnHhUr3fO6oFmkOrvs2BAFNXXY= github.com/zclconf/go-cty v1.12.1/go.mod h1:s9IfD1LK5ccNMSWCVFCE2rJfHiZgi7JijgeWIMfhLvA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/oauth2 v0.4.0 h1:NF0gk8LVPg1Ml7SSbGyySuoxdsXitj7TvgvuRxIMc/M= golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= terraform-svchost-0.0.1/label_iter.go000066400000000000000000000025701436362640700176500ustar00rootroot00000000000000package svchost import ( "strings" ) // A labelIter allows iterating over domain name labels. // // This type is copied from golang.org/x/net/idna, where it is used // to segment hostnames into their separate labels for analysis. We use // it for the same purpose here, in ForComparison. type labelIter struct { orig string slice []string curStart int curEnd int i int } func (l *labelIter) reset() { l.curStart = 0 l.curEnd = 0 l.i = 0 } func (l *labelIter) done() bool { return l.curStart >= len(l.orig) } func (l *labelIter) result() string { if l.slice != nil { return strings.Join(l.slice, ".") } return l.orig } func (l *labelIter) label() string { if l.slice != nil { return l.slice[l.i] } p := strings.IndexByte(l.orig[l.curStart:], '.') l.curEnd = l.curStart + p if p == -1 { l.curEnd = len(l.orig) } return l.orig[l.curStart:l.curEnd] } // next sets the value to the next label. It skips the last label if it is empty. func (l *labelIter) next() { l.i++ if l.slice != nil { if l.i >= len(l.slice) || l.i == len(l.slice)-1 && l.slice[l.i] == "" { l.curStart = len(l.orig) } } else { l.curStart = l.curEnd + 1 if l.curStart == len(l.orig)-1 && l.orig[l.curStart] == '.' { l.curStart = len(l.orig) } } } func (l *labelIter) set(s string) { if l.slice == nil { l.slice = strings.Split(l.orig, ".") } l.slice[l.i] = s } terraform-svchost-0.0.1/svchost.go000066400000000000000000000175121436362640700172410ustar00rootroot00000000000000// Package svchost deals with the representations of the so-called "friendly // hostnames" that we use to represent systems that provide Terraform-native // remote services, such as module registry, remote operations, etc. // // Friendly hostnames are specified such that, as much as possible, they // are consistent with how web browsers think of hostnames, so that users // can bring their intuitions about how hostnames behave when they access // a Terraform Enterprise instance's web UI (or indeed any other website) // and have this behave in a similar way. package svchost import ( "errors" "fmt" "strconv" "strings" "golang.org/x/net/idna" ) // Hostname is specialized name for string that indicates that the string // has been converted to (or was already in) the storage and comparison form. // // Hostname values are not suitable for display in the user-interface. Use // the ForDisplay method to obtain a form suitable for display in the UI. // // Unlike user-supplied hostnames, strings of type Hostname (assuming they // were constructed by a function within this package) can be compared for // equality using the standard Go == operator. type Hostname string // acePrefix is the ASCII Compatible Encoding prefix, used to indicate that // a domain name label is in "punycode" form. const acePrefix = "xn--" // displayProfile is a very liberal idna profile that we use to do // normalization for display without imposing validation rules. var displayProfile = idna.New( idna.MapForLookup(), idna.Transitional(true), ) // ForDisplay takes a user-specified hostname and returns a normalized form of // it suitable for display in the UI. // // If the input is so invalid that no normalization can be performed then // this will return the input, assuming that the caller still wants to // display _something_. This function is, however, more tolerant than the // other functions in this package and will make a best effort to prepare // _any_ given hostname for display. // // For validation, use either IsValid (for explicit validation) or // ForComparison (which implicitly validates, returning an error if invalid). func ForDisplay(given string) string { var portPortion string if colonPos := strings.Index(given, ":"); colonPos != -1 { given, portPortion = given[:colonPos], given[colonPos:] } portPortion, _ = normalizePortPortion(portPortion) ascii, err := displayProfile.ToASCII(given) if err != nil { return given + portPortion } display, err := displayProfile.ToUnicode(ascii) if err != nil { return given + portPortion } return display + portPortion } // IsValid returns true if the given user-specified hostname is a valid // service hostname. // // Validity is determined by complying with the RFC 5891 requirements for // names that are valid for domain lookup (section 5), with the additional // requirement that user-supplied forms must not _already_ contain // Punycode segments. func IsValid(given string) bool { _, err := ForComparison(given) return err == nil } // ForComparison takes a user-specified hostname and returns a normalized // form of it suitable for storage and comparison. The result is not suitable // for display to end-users because it uses Punycode to represent non-ASCII // characters, and this form is unreadable for non-ASCII-speaking humans. // // The result is typed as Hostname -- a specialized name for string -- so that // other APIs can make it clear within the type system whether they expect a // user-specified or display-form hostname or a value already normalized for // comparison. // // The returned Hostname is not valid if the returned error is non-nil. func ForComparison(given string) (Hostname, error) { var portPortion string if colonPos := strings.Index(given, ":"); colonPos != -1 { given, portPortion = given[:colonPos], given[colonPos:] } var err error portPortion, err = normalizePortPortion(portPortion) if err != nil { // We can get in here if someone has incorrectly specified a URL // instead of a hostname, because normalizePortPortion will try to // treat the colon after the scheme as the port number separator. // We'll return a more specific error message for that situation. given = strings.ToLower(given) if given == "https" || given == "http" { // Technically it's valid to have a host called "https" or "http" // which would generate a false positive here with input like // "http:foo", but we can only get here if the hostname exactly // matches one of the schemes _and_ the port number is also invalid. return Hostname(""), fmt.Errorf("need just a hostname and optional port number, not a full URL") } return Hostname(""), err } if given == "" { return Hostname(""), fmt.Errorf("empty string is not a valid hostname") } // First we'll apply our additional constraint that Punycode must not // be given directly by the user. This is not an IDN specification // requirement, but we prohibit it to force users to use human-readable // hostname forms within Terraform configuration. labels := labelIter{orig: given} for ; !labels.done(); labels.next() { label := labels.label() if label == "" { return Hostname(""), fmt.Errorf( "hostname contains empty label (two consecutive periods)", ) } if strings.HasPrefix(label, acePrefix) { return Hostname(""), fmt.Errorf( "hostname label %q specified in punycode format; service hostnames must be given in unicode", label, ) } } result, err := idna.Lookup.ToASCII(given) if err != nil { return Hostname(""), err } return Hostname(result + portPortion), nil } // ForDisplay returns a version of the receiver that is appropriate for display // in the UI. This includes converting any punycode labels to their // corresponding Unicode characters. // // A round-trip through ForComparison and this ForDisplay method does not // guarantee the same result as calling this package's top-level ForDisplay // function, since a round-trip through the Hostname type implies stricter // handling than we do when doing basic display-only processing. func (h Hostname) ForDisplay() string { given := string(h) var portPortion string if colonPos := strings.Index(given, ":"); colonPos != -1 { given, portPortion = given[:colonPos], given[colonPos:] } // We don't normalize the port portion here because we assume it's // already been normalized on the way in. result, err := idna.Lookup.ToUnicode(given) if err != nil { // Should never happen, since type Hostname indicates that a string // passed through our validation rules. panic(fmt.Errorf("ForDisplay called on invalid Hostname: %s", err)) } return result + portPortion } func (h Hostname) String() string { return string(h) } func (h Hostname) GoString() string { return fmt.Sprintf("svchost.Hostname(%q)", string(h)) } // normalizePortPortion attempts to normalize the "port portion" of a hostname, // which begins with the first colon in the hostname and should be followed // by a string of decimal digits. // // If the port portion is valid, a normalized version of it is returned along // with a nil error. // // If the port portion is invalid, the input string is returned verbatim along // with a non-nil error. // // An empty string is a valid port portion representing the absence of a port. // If non-empty, the first character must be a colon. func normalizePortPortion(s string) (string, error) { if s == "" { return s, nil } if s[0] != ':' { // should never happen, since caller tends to guarantee the presence // of a colon due to how it's extracted from the string. return s, errors.New("port portion is missing its initial colon") } numStr := s[1:] num, err := strconv.Atoi(numStr) if err != nil { return s, errors.New("port portion contains non-digit characters") } if num == 443 { return "", nil // ":443" is the default } if num > 65535 { return s, errors.New("port number is greater than 65535") } return fmt.Sprintf(":%d", num), nil } terraform-svchost-0.0.1/svchost_test.go000066400000000000000000000103041436362640700202700ustar00rootroot00000000000000package svchost import "testing" func TestForDisplay(t *testing.T) { tests := []struct { Input string Want string }{ { "", "", }, { "example.com", "example.com", }, { "invalid", "invalid", }, { "localhost", "localhost", }, { "localhost:1211", "localhost:1211", }, { "HashiCorp.com", "hashicorp.com", }, { "Испытание.com", "испытание.com", }, { "münchen.de", // this is a precomposed u with diaeresis "münchen.de", // this is a precomposed u with diaeresis }, { "münchen.de", // this is a separate u and combining diaeresis "münchen.de", // this is a precomposed u with diaeresis }, { "example.com:443", "example.com", }, { "example.com:81", "example.com:81", }, { "example.com:boo", "example.com:boo", // invalid, but tolerated for display purposes }, { "example.com:boo:boo", "example.com:boo:boo", // invalid, but tolerated for display purposes }, { "example.com:081", "example.com:81", }, } for _, test := range tests { t.Run(test.Input, func(t *testing.T) { got := ForDisplay(test.Input) if got != test.Want { t.Errorf("wrong result\ninput: %s\ngot: %s\nwant: %s", test.Input, got, test.Want) } }) } } func TestForComparison(t *testing.T) { tests := []struct { Input string Want string Err string }{ { "", "", `empty string is not a valid hostname`, }, { "example.com", "example.com", ``, }, { "example.com:443", "example.com", ``, }, { "example.com:81", "example.com:81", ``, }, { "example.com:081", "example.com:81", ``, }, { "invalid", "invalid", ``, // the "invalid" TLD is, confusingly, a valid hostname syntactically }, { "localhost", // supported for local testing only "localhost", ``, }, { "localhost:1211", // supported for local testing only "localhost:1211", ``, }, { "HashiCorp.com", "hashicorp.com", ``, }, { "1example.com", "1example.com", ``, }, { "Испытание.com", "xn--80akhbyknj4f.com", ``, }, { "münchen.de", // this is a precomposed u with diaeresis "xn--mnchen-3ya.de", ``, }, { "münchen.de", // this is a separate u and combining diaeresis "xn--mnchen-3ya.de", ``, }, { "blah..blah", "", `hostname contains empty label (two consecutive periods)`, }, { "example.com:boo", "", `port portion contains non-digit characters`, }, { "example.com:80:boo", "", `port portion contains non-digit characters`, }, { "example.com:9999999", "", `port number is greater than 65535`, }, { "https://example.com", "", `need just a hostname and optional port number, not a full URL`, }, { "https://example.com:80", "", `need just a hostname and optional port number, not a full URL`, }, { "http:80", // This is weird but technically valid as a host called "http" "http:80", ``, }, { "https:80", // This is weird but technically valid as a host called "https" "https:80", ``, }, } for _, test := range tests { t.Run(test.Input, func(t *testing.T) { got, err := ForComparison(test.Input) var errStr string if err != nil { errStr = err.Error() } if errStr != test.Err { t.Errorf("unexpected error\ngot error: %s\nwant error: %s", err, test.Err) } if string(got) != test.Want { t.Errorf("wrong result\ninput: %s\ngot: %s\nwant: %s", test.Input, got, test.Want) } }) } } func TestHostnameForDisplay(t *testing.T) { tests := []struct { Input string Want string }{ { "example.com", "example.com", }, { "example.com:81", "example.com:81", }, { "xn--80akhbyknj4f.com", "испытание.com", }, { "xn--80akhbyknj4f.com:8080", "испытание.com:8080", }, { "xn--mnchen-3ya.de", "münchen.de", // this is a precomposed u with diaeresis }, } for _, test := range tests { t.Run(test.Input, func(t *testing.T) { got := Hostname(test.Input).ForDisplay() if got != test.Want { t.Errorf("wrong result\ninput: %s\ngot: %s\nwant: %s", test.Input, got, test.Want) } }) } }