From d554dea58f3195714b2e8276f0ae5aafbb9fe723 Mon Sep 17 00:00:00 2001 From: Stan Lagun Date: Wed, 20 Sep 2017 21:08:20 -0700 Subject: [PATCH] Adds ability to configure http(s) transport for the image endpoints --- docs/image-name-translation.md | 83 ++++ pkg/imagetranslation/interface.go | 51 ++- pkg/imagetranslation/translator.go | 104 ++++- pkg/imagetranslation/translator_test.go | 22 +- pkg/imagetranslation/transport_test.go | 561 +++++++++++++++++++++++ pkg/utils/download.go | 139 +++++- tests/e2e/image_name_translation_test.go | 5 +- 7 files changed, 929 insertions(+), 36 deletions(-) create mode 100644 pkg/imagetranslation/transport_test.go diff --git a/docs/image-name-translation.md b/docs/image-name-translation.md index dfd3e6339..10c3c8c92 100644 --- a/docs/image-name-translation.md +++ b/docs/image-name-translation.md @@ -85,3 +85,86 @@ never had Virtlet running. There can be any number of `VirtletImageMapping` resource. However, currently all such mappings must be in the `kube-system` namespace. `VirtletImageMapping` resource have a precedence over file-based configs for ambiguous image names. Thus it is convenient to put defaults into static config files and then override them with `VirtletImageMapping` resources when needed. + +## Configure HTTP transport for image download + +By default, the image downloader uses default transport settings: system-wide CA certificates for HTTPS URLs, +up to 9 redirects and proxy from the `HTTP_PROXY`/`HTTPS_PROXY` environment variables. However, with image translation +configs it is possible to override these default and provide custom transport configuration. + +Transport settings are grouped into profiles, each with the name and bunch of configuration settings. Each translation +rule may optionally have `transport` attribute set to profile name to be used for the image URL of that rule. +Below is an example of translation config that has all possible transport settings though all of them are optional: + +```yaml +translations: +- name: mySmallImage + url: https://my.host.loc/small.qcow2 + transport: my-server +- name: myImage + url: https://my.host.loc/big.qcow2 + transport: my-server +transports: + my-server: + timeout: 30000 # in ms. 0 = no timeout (default) + maxRedirects: 1 # at most 1 redirect allowed (i.e. 2 HTTP requests). null or missing value = any number of redirects + proxy: http://my-proxy.loc:8080 + tls: # optional TLS settings. Use default system settings when not specified + certificates: # there can be any mumber of certificates. Both CA and client certificates are put here + - cert: | + -----BEGIN CERTIFICATE----- + # CA PEM block goes here + # CA certificates are recognized by IsCA:TRUE flag in the certificate. Private key is not needed in this case + # CA certificates are appended to the Linux system-wide list + -----END CERTIFICATE----- + + - cert: | + -----BEGIN CERTIFICATE----- + # Client-based authentication certificate PEM block goes here + # There can be several certificates put together if they share a single key + -----END CERTIFICATE----- + + key: | + -----BEGIN RSA PRIVATE KEY----- + # PEM-encoded private key + # for certificate-based client authentication private key must be present + # Also the key is not required if it already contained in the cert PEM + -----END RSA PRIVATE KEY----- + + serverName: my.host.com # because the certificate is for .com but we're connecting to .loc + insecure: false # when true, no server certificate validation is going to be performed +``` + +When no transport profile is specified for translation rule, the default system settings are used. However, +since the default value for `transport` attribute is an empty string, defining profile with empty name can +be used to override this default for all images in that particular config: + +```yaml +translations: +- name: mySmallImage + url: https://my.host.loc/small.qcow2 +- name: myImage + url: https://my.host.loc/big.qcow2 +transports: + "": + proxy: http://my-proxy.loc:8080 # proxy for all images without explicit transport name +``` + +Of course, the same settings can be put into `VirtletImageMapping` objects: + +```yaml +apiVersion: "virtlet.k8s/v1" +kind: VirtletImageMapping +metadata: + name: primary + namespace: kube-system +spec: + translations: + - name: mySmallImage + url: https://my.host.loc/small.qcow2 + - name: myImage + url: https://my.host.loc/big.qcow2 + transports: + "": + proxy: http://my-proxy.loc:8080 # proxy for all images without explicit transport name +``` diff --git a/pkg/imagetranslation/interface.go b/pkg/imagetranslation/interface.go index 8d6530432..788c70303 100644 --- a/pkg/imagetranslation/interface.go +++ b/pkg/imagetranslation/interface.go @@ -20,30 +20,70 @@ import "github.com/Mirantis/virtlet/pkg/utils" // TranslationRule represents a single translation rule from either name or regexp to Endpoint type TranslationRule struct { - // Name defines a mapping from a fixed name Name string `yaml:"name,omitempty" json:"name,omitempty"` // Regex defines a mapping from all names that match this regexp. In this case replacements can be used for Endpoint.Url Regex string `yaml:"regexp,omitempty" json:"regexp,omitempty"` - // Endpoint that this rule maps to - utils.Endpoint `yaml:",inline" json:",inline"` + // Url is the image URL + Url string `yaml:"url,omitempty" json:"url,omitempty"` + + // Transport is the optional transport profile name to be used for the downloading + Transport string `yaml:"transport,omitempty" json:"transport,omitempty"` } // ImageTranslation is a single translation config with optional prefix name type ImageTranslation struct { - // Prefix allows to have several config-sets and distinguish them by using `prefix/imageName` notation. Optional. Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"` // Rules is a list of translations Rules []TranslationRule `yaml:"translations" json:"translations"` + + // Transports is a map of available transport profiles available for endpoints + Transports map[string]TransportProfile `yaml:"transports" json:"transports"` +} + +// TransportProfile contains all the http transport settings +type TransportProfile struct { + // MaxRedirects is the maximum number of redirects that downloader is allowed to follow. Default is 9 (download fails on request #10) + MaxRedirects *int `yaml:"maxRedirects,omitempty" json:"maxRedirects,omitempty"` + + // TLS config + TLS *TLSConfig `yaml:"tls,omitempty" json:"tls,omitempty"` + + // TimeoutMilliseconds specifies a time limit in milliseconds for http(s) download request. <= 0 is no timeout (default) + TimeoutMilliseconds int `yaml:"timeout,omitempty" json:"timeout,omitempty"` + + // Proxy server to use for downloading + Proxy string `yaml:"proxy,omitempty" json:"proxy,omitempty"` +} + +// TLSConfig has the TLS transport parameters +type TLSConfig struct { + // Certificates - TLS certificates to use for connection + Certificates []TLSCertificate `yaml:"certificates,omitempty" json:"certificates,omitempty"` + + // ServerName is used to verify the hostname on the returned certificates. Needed when url points to domain that + // differs from CN of certificate + ServerName string `yaml:"serverName,omitempty" json:"serverName,omitempty"` + + // Insecure is a flag to bypass server certificate validation + Insecure bool `yaml:"insecure,omitempty" json:"insecure,omitempty"` +} + +// TLSCertificate has the x509 certificate PEM data with optional PEM private key +type TLSCertificate struct { + // Cert certificate (PEM) block + Cert string `yaml:"cert,omitempty" json:"cert,omitempty"` + + // Key - keypair (PEM) block + Key string `yaml:"key,omitempty" json:"key,omitempty"` } // TranslationConfig represents a single config (prefix + rule list) in a config-set type TranslationConfig interface { - // Name returns the config name (any string identifier) Name() string @@ -62,7 +102,6 @@ type ConfigSource interface { // ImageNameTranslator is the main translator interface type ImageNameTranslator interface { - // LoadConfigs initializes translator with configs from supplied data sources. All previous mappings are discarded. LoadConfigs(sources ...ConfigSource) diff --git a/pkg/imagetranslation/translator.go b/pkg/imagetranslation/translator.go index 92c285adb..144cca172 100644 --- a/pkg/imagetranslation/translator.go +++ b/pkg/imagetranslation/translator.go @@ -17,9 +17,16 @@ limitations under the License. package imagetranslation import ( + "crypto" + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" "os" "regexp" "strings" + "time" "github.com/golang/glog" @@ -54,6 +61,99 @@ func (t *imageNameTranslator) LoadConfigs(sources ...ConfigSource) { t.translations = translations } +func convertEndpoint(rule TranslationRule, config *ImageTranslation) utils.Endpoint { + profile, exists := config.Transports[rule.Transport] + if !exists { + return utils.Endpoint{ + Url: rule.Url, + MaxRedirects: -1, + } + } + if profile.TimeoutMilliseconds < 0 { + profile.TimeoutMilliseconds = 0 + } + maxRedirects := -1 + if profile.MaxRedirects != nil { + maxRedirects = *profile.MaxRedirects + } + + var tlsConfig *utils.TLSConfig + if profile.TLS != nil { + var certificates []utils.TLSCertificate + for i, record := range profile.TLS.Certificates { + var x509Certs []*x509.Certificate + var privateKey crypto.PrivateKey + + for _, data := range [2]string{record.Key, record.Cert} { + dataBytes := []byte(data) + for { + block, rest := pem.Decode(dataBytes) + if block == nil { + break + } + if block.Type == "CERTIFICATE" { + c, err := x509.ParseCertificate(block.Bytes) + if err != nil { + glog.V(2).Infof("error decoding certificate #%d from transport profile %s", i, rule.Transport) + } else { + x509Certs = append(x509Certs, c) + } + } else if privateKey == nil && strings.HasSuffix(block.Type, "PRIVATE KEY") { + k, err := parsePrivateKey(block.Bytes) + if err != nil { + glog.V(2).Infof("error decoding private key #%d from transport profile %s", i, rule.Transport) + } else { + privateKey = k + } + } + dataBytes = rest + } + } + + for _, c := range x509Certs { + certificates = append(certificates, utils.TLSCertificate{ + Certificate: c, + PrivateKey: privateKey, + }) + } + } + + tlsConfig = &utils.TLSConfig{ + ServerName: profile.TLS.ServerName, + Insecure: profile.TLS.Insecure, + Certificates: certificates, + } + } + + return utils.Endpoint{ + Url: rule.Url, + Timeout: time.Millisecond * time.Duration(profile.TimeoutMilliseconds), + Proxy: profile.Proxy, + ProfileName: rule.Transport, + MaxRedirects: maxRedirects, + TLS: tlsConfig, + } +} + +func parsePrivateKey(der []byte) (crypto.PrivateKey, error) { + if key, err := x509.ParsePKCS1PrivateKey(der); err == nil { + return key, nil + } + if key, err := x509.ParsePKCS8PrivateKey(der); err == nil { + switch key := key.(type) { + case *rsa.PrivateKey, *ecdsa.PrivateKey: + return key, nil + default: + return nil, errors.New("tls: found unknown private key type in PKCS#8 wrapping") + } + } + if key, err := x509.ParseECPrivateKey(der); err == nil { + return key, nil + } + + return nil, errors.New("tls: failed to parse private key") +} + // Translate implements ImageNameTranslator Translate func (t *imageNameTranslator) Translate(name string) utils.Endpoint { for _, translation := range t.translations { @@ -67,7 +167,7 @@ func (t *imageNameTranslator) Translate(name string) utils.Endpoint { } for _, r := range translation.Rules { if r.Name != "" && r.Name == unprefixedName { - return r.Endpoint + return convertEndpoint(r, translation) } } if !t.AllowRegexp { @@ -85,7 +185,7 @@ func (t *imageNameTranslator) Translate(name string) utils.Endpoint { submatchIndexes := re.FindStringSubmatchIndex(unprefixedName) if len(submatchIndexes) > 0 { r.Url = string(re.ExpandString(nil, r.Url, unprefixedName, submatchIndexes)) - return r.Endpoint + return convertEndpoint(r, translation) } } } diff --git a/pkg/imagetranslation/translator_test.go b/pkg/imagetranslation/translator_test.go index ef714c0d0..5c5f36944 100644 --- a/pkg/imagetranslation/translator_test.go +++ b/pkg/imagetranslation/translator_test.go @@ -18,8 +18,6 @@ package imagetranslation import ( "testing" - - "github.com/Mirantis/virtlet/pkg/utils" ) // TestTranslations tests how image names are translated with various translation rules @@ -29,21 +27,15 @@ func TestTranslations(t *testing.T) { Rules: []TranslationRule{ { Regex: `^image(\d+)`, - Endpoint: utils.Endpoint{ - Url: "http://example.net/image_$1.qcow2", - }, + Url: "http://example.net/image_$1.qcow2", }, { Regex: `image(\d+)`, - Endpoint: utils.Endpoint{ - Url: "http://example.net/alt_$1.qcow2", - }, + Url: "http://example.net/alt_$1.qcow2", }, { Name: "image1", - Endpoint: utils.Endpoint{ - Url: "https://example.net/base.qcow2", - }, + Url: "https://example.net/base.qcow2", }, }, }, @@ -52,15 +44,11 @@ func TestTranslations(t *testing.T) { Rules: []TranslationRule{ { Regex: `^linux/(\d+\.\d+)`, - Endpoint: utils.Endpoint{ - Url: "http://acme.org/linux_$1.qcow2", - }, + Url: "http://acme.org/linux_$1.qcow2", }, { Name: "linux/1", - Endpoint: utils.Endpoint{ - Url: "https://acme.org/linux.qcow2", - }, + Url: "https://acme.org/linux.qcow2", }, }, }, diff --git a/pkg/imagetranslation/transport_test.go b/pkg/imagetranslation/transport_test.go new file mode 100644 index 000000000..7fc1cc0bb --- /dev/null +++ b/pkg/imagetranslation/transport_test.go @@ -0,0 +1,561 @@ +/* +Copyright 2017 Mirantis + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package imagetranslation + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "math/big" + "net" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/Mirantis/virtlet/pkg/utils" +) + +func translate(config ImageTranslation, name string, server *httptest.Server) utils.Endpoint { + for i, rule := range config.Rules { + config.Rules[i].Url = strings.Replace(rule.Url, "%", server.Listener.Addr().String(), 1) + } + configs := map[string]ImageTranslation{"config": config} + + translator := NewImageNameTranslator() + translator.LoadConfigs(NewFakeConfigSource(configs)) + return translator.Translate(name) +} + +func intptr(v int) *int { + return &v +} + +func download(t *testing.T, proto string, config ImageTranslation, name string, server *httptest.Server) { + downloader := utils.NewDownloader(proto) + _, err := downloader.DownloadFile(translate(config, name, server)) + if err != nil { + t.Fatal(err) + } +} + +func TestMain(m *testing.M) { + os.Unsetenv("HTTP_PROXY") + os.Unsetenv("HTTPS_PROXY") + m.Run() +} + +func TestImageDownload(t *testing.T) { + handled := false + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handled = true + if r.URL.String() != "/base.qcow2" { + t.Fatalf("unexpected URL %s", r.URL) + } + }) + ts := httptest.NewServer(handler) + defer ts.Close() + + config := ImageTranslation{ + Prefix: "test", + Rules: []TranslationRule{ + { + Name: "image1", + Url: "http://%/base.qcow2", + }, + }, + } + + download(t, "https", config, "test/image1", ts) + if !handled { + t.Fatal("image was not downloaded") + } +} + +func TestImageDownloadRedirects(t *testing.T) { + var urls []string + var handledCount int + var maxRedirects int + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + urls = append(urls, r.URL.String()) + if handledCount < maxRedirects { + w.Header().Add("Location", fmt.Sprintf("/file%d", handledCount+1)) + w.WriteHeader(301) + } + handledCount++ + }) + ts := httptest.NewServer(handler) + defer ts.Close() + + config := ImageTranslation{ + Rules: []TranslationRule{ + { + Name: "image1", + Url: "http://%/base.qcow2", + Transport: "profile1", + }, + { + Name: "image2", + Url: "http://%/base.qcow2", + Transport: "profile2", + }, + { + Name: "image3", + Url: "http://%/base.qcow2", + Transport: "profile3", + }, + { + Name: "image4", + Url: "http://%/base.qcow2", + Transport: "profile4", + }, + }, + Transports: map[string]TransportProfile{ + "profile1": {MaxRedirects: intptr(0)}, + "profile2": {MaxRedirects: intptr(1)}, + "profile3": {MaxRedirects: intptr(5)}, + "profile4": {MaxRedirects: nil}, + }, + } + + downloader := utils.NewDownloader("http") + for _, tst := range []struct { + name string + image string + mr int + expectedUrls int + mustFail bool + message string + }{ + { + name: "0 redirects, 0 allowed", + image: "image1", + mr: 0, + expectedUrls: 1, + mustFail: false, + message: "image download without redirects must succeed even if no redirects allowed", + }, + { + name: "1 redirect, 0 allowed", + image: "image1", + mr: 1, + expectedUrls: 1, + mustFail: true, + message: "image download with redirects cannot succeed when no redirects allowed", + }, + { + name: "1 redirect, 1 allowed", + image: "image2", + mr: 1, + expectedUrls: 2, + mustFail: false, + message: "image download must succeed when number of redirects doesn't exceed maximum", + }, + { + name: "5 redirect, 5 allowed", + image: "image3", + mr: 5, + expectedUrls: 6, + mustFail: false, + message: "image download must succeed when number of redirects doesn't exceed maximum", + }, + { + name: "2 redirect, 1 allowed", + image: "image2", + mr: 2, + expectedUrls: 2, + mustFail: true, + message: "image download must fail when number of redirects exceeds maximum value", + }, + { + name: "10 redirect, 5 allowed", + image: "image3", + mr: 10, + expectedUrls: 6, + mustFail: true, + message: "image download must fail when number of redirects exceeds maximum value", + }, + { + name: "9 redirect, 9 (default) allowed", + image: "image4", + mr: 9, + expectedUrls: 10, + mustFail: false, + message: "image download must not fail when number of redirects doesn't exceed maximum value", + }, + { + name: "10 redirect, 9 (default) allowed", + image: "image4", + mr: 10, + expectedUrls: 10, + mustFail: true, + message: "image download must fail when number of redirects exceeds maximum value", + }, + } { + t.Run(tst.name, func(t *testing.T) { + urls = nil + handledCount = 0 + maxRedirects = tst.mr + _, err := downloader.DownloadFile(translate(config, tst.image, ts)) + if handledCount == 0 { + t.Error("http handler wasn't called") + } else if (err != nil) != tst.mustFail { + t.Error(tst.message) + } + + if len(urls) != tst.expectedUrls { + t.Errorf("unexpected number of redirects for %q: %d != %d", tst.image, len(urls), tst.expectedUrls) + } else { + for i, r := range urls { + if i == 0 && r != "/base.qcow2" || i > 0 && r != fmt.Sprintf("/file%d", i) { + t.Errorf("unexpected URL #%d %s for %q", i, r, tst.image) + } + } + } + }) + } +} + +func TestImageDownloadWithProxy(t *testing.T) { + handled := false + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handled = true + if r.URL.String() != "http://example.net/base.qcow2" { + t.Fatalf("proxy server was used for wrong URL %v", r.URL) + } + }) + ts := httptest.NewServer(handler) + defer ts.Close() + + config := ImageTranslation{ + Rules: []TranslationRule{ + { + Name: "image1", + Url: "example.net/base.qcow2", + }, + }, + Transports: map[string]TransportProfile{ + "": {Proxy: "http://" + ts.Listener.Addr().String()}, + }, + } + + download(t, "http", config, "image1", ts) + if !handled { + t.Fatal("image was not downloaded") + } +} + +func TestImageDownloadWithTimeout(t *testing.T) { + handled := false + var timeout time.Duration + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handled = true + time.Sleep(timeout) + }) + ts := httptest.NewServer(handler) + defer ts.Close() + + config := ImageTranslation{ + Rules: []TranslationRule{ + { + Name: "image", + Url: "%/base.qcow2", + }, + }, + Transports: map[string]TransportProfile{ + "": {TimeoutMilliseconds: 250}, + }, + } + + downloader := utils.NewDownloader("http") + for _, tst := range []struct { + name string + timeout time.Duration + mustFail bool + }{ + { + name: "positive test", + timeout: time.Millisecond * 50, + mustFail: false, + }, + { + name: "negative test", + timeout: time.Millisecond * 350, + mustFail: true, + }, + } { + t.Run(tst.name, func(t *testing.T) { + handled = false + timeout = tst.timeout + _, err := downloader.DownloadFile(translate(config, "image", ts)) + if err == nil && tst.mustFail { + t.Error("no error happened when timeout was expected") + } else if err != nil && !tst.mustFail { + t.Fatal(err) + } + if !handled { + t.Fatal("image was not downloaded") + } + }) + } +} + +func generateCert(t *testing.T, isCA bool, host string, signer *x509.Certificate, key *rsa.PrivateKey) (*x509.Certificate, *rsa.PrivateKey) { + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 64) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + t.Fatal(err) + } + if key == nil { + key, err = rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + } + + template := &x509.Certificate{ + SerialNumber: serialNumber, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + } + + if ip := net.ParseIP(host); ip != nil { + template.IPAddresses = []net.IP{ip} + } else { + template.DNSNames = []string{host} + } + + if isCA { + template.IsCA = true + template.KeyUsage |= x509.KeyUsageCertSign + } + + if signer == nil { + signer = template + } + + der, err := x509.CreateCertificate(rand.Reader, template, signer, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return cert, key +} + +func encodePEMCert(cert *x509.Certificate) string { + buf := bytes.NewBufferString("") + pem.Encode(buf, &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}) + return buf.String() +} + +func encodePEMKey(key *rsa.PrivateKey) string { + buf := bytes.NewBufferString("") + pem.Encode(buf, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + return buf.String() +} + +func TestImageDownloadTLS(t *testing.T) { + ca, caKey := generateCert(t, true, "CA", nil, nil) + cert, key := generateCert(t, false, "127.0.0.1", ca, caKey) + + handled := false + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handled = r.TLS != nil + }) + ts := httptest.NewUnstartedServer(handler) + ts.TLS = &tls.Config{ + Certificates: []tls.Certificate{ + { + Certificate: [][]byte{cert.Raw}, + PrivateKey: key, + }, + }, + } + ts.StartTLS() + defer ts.Close() + + config := ImageTranslation{ + Rules: []TranslationRule{ + { + Name: "image1", + Url: "%/base.qcow2", + Transport: "tlsProfile", + }, + }, + Transports: map[string]TransportProfile{ + "tlsProfile": { + TLS: &TLSConfig{ + Certificates: []TLSCertificate{ + {Cert: encodePEMCert(ca)}, + }, + }, + }, + }, + } + + download(t, "https", config, "image1", ts) + if !handled { + t.Fatal("image was not downloaded") + } +} + +func TestImageDownloadTLSWithClientCerts(t *testing.T) { + ca, caKey := generateCert(t, true, "CA", nil, nil) + serverCert, serverKey := generateCert(t, false, "127.0.0.1", ca, caKey) + clientCert, clientKey := generateCert(t, false, "127.0.0.1", serverCert, serverKey) + + handled := false + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handled = r.TLS != nil + if len(r.TLS.PeerCertificates) != 1 { + t.Fatal("client certificate wasn't used") + } + if r.TLS.PeerCertificates[0].SerialNumber.Cmp(clientCert.SerialNumber) != 0 { + t.Error("wrong certificate was used") + } + }) + ts := httptest.NewUnstartedServer(handler) + ts.TLS = &tls.Config{ + Certificates: []tls.Certificate{ + { + Certificate: [][]byte{serverCert.Raw}, + PrivateKey: serverKey, + }, + }, + ClientAuth: tls.RequestClientCert, + } + ts.StartTLS() + defer ts.Close() + + config := ImageTranslation{ + Rules: []TranslationRule{ + { + Name: "image", + Url: "%/base.qcow2", + Transport: "tlsProfile", + }, + }, + Transports: map[string]TransportProfile{ + "tlsProfile": { + TLS: &TLSConfig{ + Certificates: []TLSCertificate{ + { + Cert: encodePEMCert(ca), + }, + { + Cert: encodePEMCert(clientCert), + Key: encodePEMKey(clientKey), + }, + }, + }, + }, + }, + } + + download(t, "https", config, "image", ts) + if !handled { + t.Fatal("image was not downloaded") + } +} + +func TestImageDownloadTLSWithServerName(t *testing.T) { + ca, caKey := generateCert(t, true, "CA", nil, nil) + cert, key := generateCert(t, false, "test.corp", ca, caKey) + + handled := false + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handled = r.TLS != nil + }) + ts := httptest.NewUnstartedServer(handler) + ts.TLS = &tls.Config{ + Certificates: []tls.Certificate{ + { + Certificate: [][]byte{cert.Raw}, + PrivateKey: key, + }, + }, + } + ts.StartTLS() + defer ts.Close() + + config := ImageTranslation{ + Rules: []TranslationRule{ + { + Name: "image", + Url: "%/base.qcow2", + Transport: "tlsProfile", + }, + }, + Transports: map[string]TransportProfile{ + "tlsProfile": { + TLS: &TLSConfig{ + Certificates: []TLSCertificate{ + {Cert: encodePEMCert(ca)}, + }, + ServerName: "test.corp", + }, + }, + }, + } + + download(t, "https", config, "image", ts) + if !handled { + t.Fatal("image was not downloaded") + } +} + +func TestImageDownloadTLSWithoutCertValidation(t *testing.T) { + handled := false + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handled = r.TLS != nil + }) + ts := httptest.NewUnstartedServer(handler) + ts.StartTLS() + defer ts.Close() + + config := ImageTranslation{ + Rules: []TranslationRule{ + { + Name: "image", + Url: "%/base.qcow2", + Transport: "tlsProfile", + }, + }, + Transports: map[string]TransportProfile{ + "tlsProfile": { + TLS: &TLSConfig{Insecure: true}, + }, + }, + } + + download(t, "https", config, "image", ts) + if !handled { + t.Fatal("image was not downloaded") + } +} diff --git a/pkg/utils/download.go b/pkg/utils/download.go index 9681ba54b..9addd7d88 100644 --- a/pkg/utils/download.go +++ b/pkg/utils/download.go @@ -17,30 +17,69 @@ limitations under the License. package utils import ( + "crypto" + "crypto/tls" + "crypto/x509" "fmt" "io" "io/ioutil" + "net" "net/http" + "net/url" "strings" + "time" "github.com/golang/glog" ) // Endpoint contains all the endpoint parameters needed to download a file -// TODO: add TLS and other HTTP parameters here type Endpoint struct { - Url string `yaml:"url,omitempty" json:"url,omitempty"` + // Url is the image URL + Url string + + // MaxRedirects is the maximum number of redirects that downloader is allowed to follow. -1 for stdlib default (fails on request #10) + MaxRedirects int + + // TLS is the TLS config + TLS *TLSConfig + + // Timeout specifies a time limit for http(s) download request. <= 0 is no timeout (default) + Timeout time.Duration + + // Proxy is the proxy server to use. Default = use proxy from HTTP_PROXY environment variable + Proxy string + + // Transport profile name for this endpoint. Provided for logging/debugging + ProfileName string +} + +// TLSConfig has the TLS transport parameters +type TLSConfig struct { + // Certificates to use (both CA and for client authentication) + Certificates []TLSCertificate + + // ServerName is needed when connecting to domain other that certificate was issued for + ServerName string + + // Insecure skips certificate verification + Insecure bool +} + +// TLSCertificate is a x509 certificate with optional private key +type TLSCertificate struct { + // Certificate is the x509 certificate + Certificate *x509.Certificate + + // PrivateKey is the private key needed for certificate-based client authentication + PrivateKey crypto.PrivateKey } // Downloader is an interface for downloading files from web type Downloader interface { - // DownloadFile downloads the specified file and returns path - // to it + // DownloadFile downloads the specified file and returns path to it DownloadFile(endpoint Endpoint) (string, error) } - - type defaultDownloader struct { protocol string } @@ -53,12 +92,98 @@ func NewDownloader(protocol string) Downloader { return &defaultDownloader{protocol} } +func buildTLSConfig(config *TLSConfig, profileName string) (*tls.Config, error) { + var certificates []tls.Certificate + roots, err := x509.SystemCertPool() + if err != nil { + roots = x509.NewCertPool() + } + for _, cert := range config.Certificates { + if cert.Certificate.IsCA { + roots.AddCert(cert.Certificate) + } else if cert.PrivateKey != nil { + certificates = append(certificates, tls.Certificate{ + Certificate: [][]byte{cert.Certificate.Raw}, + PrivateKey: cert.PrivateKey, + }) + } else { + glog.V(3).Infof("Skipping certificate %q because it is neither CA not has a private key", cert.Certificate.SerialNumber.String()) + } + } + + return &tls.Config{ + Certificates: certificates, + RootCAs: roots, + InsecureSkipVerify: config.Insecure, + ServerName: config.ServerName, + }, nil +} + +func createTransport(endpoint Endpoint) (*http.Transport, error) { + var tlsConfig *tls.Config + var err error + if endpoint.TLS != nil { + tlsConfig, err = buildTLSConfig(endpoint.TLS, endpoint.ProfileName) + if err != nil { + return nil, err + } + } + + proxyFunc := http.ProxyFromEnvironment + if endpoint.Proxy != "" { + proxyFunc = func(*http.Request) (*url.URL, error) { + return url.Parse(endpoint.Proxy) + } + } + + return &http.Transport{ + Proxy: proxyFunc, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + DualStack: true, + }).DialContext, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + TLSClientConfig: tlsConfig, + }, nil +} + +func createHttpClient(endpoint Endpoint) (*http.Client, error) { + transport, err := createTransport(endpoint) + if err != nil { + return nil, err + } + + var checkRedirects func(req *http.Request, via []*http.Request) error + if endpoint.MaxRedirects >= 0 { + checkRedirects = func(req *http.Request, via []*http.Request) error { + if len(via) > endpoint.MaxRedirects { + return fmt.Errorf("stopped after %d redirects", endpoint.MaxRedirects) + } + return nil + } + } + + return &http.Client{ + Transport: transport, + Timeout: endpoint.Timeout, + CheckRedirect: checkRedirects, + }, nil +} + func (d *defaultDownloader) DownloadFile(endpoint Endpoint) (string, error) { url := endpoint.Url if !strings.Contains(url, "://") { url = fmt.Sprintf("%s://%s", d.protocol, url) } + client, err := createHttpClient(endpoint) + if err != nil { + return "", err + } tempFile, err := ioutil.TempFile("", "virtlet_") if err != nil { return "", err @@ -67,7 +192,7 @@ func (d *defaultDownloader) DownloadFile(endpoint Endpoint) (string, error) { glog.V(2).Infof("Start downloading %s", url) - resp, err := http.Get(url) + resp, err := client.Get(url) if err != nil { return "", err } diff --git a/tests/e2e/image_name_translation_test.go b/tests/e2e/image_name_translation_test.go index 0e0573c98..25650234e 100644 --- a/tests/e2e/image_name_translation_test.go +++ b/tests/e2e/image_name_translation_test.go @@ -23,7 +23,6 @@ import ( meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/Mirantis/virtlet/pkg/imagetranslation" - "github.com/Mirantis/virtlet/pkg/utils" "github.com/Mirantis/virtlet/tests/e2e/framework" . "github.com/Mirantis/virtlet/tests/e2e/ginkgo-ext" ) @@ -40,9 +39,7 @@ var _ = Describe("Image URL", func() { Rules: []imagetranslation.TranslationRule{ { Name: "test-image", - Endpoint: utils.Endpoint{ - Url: *cirrosLocation, - }, + Url: *cirrosLocation, }, }, },