From 8327dd0c0bca37036918a095f9fae1c6496e5571 Mon Sep 17 00:00:00 2001 From: gopenguin Date: Mon, 8 Jan 2018 00:36:03 +0100 Subject: [PATCH] Add support for fetching k8s Ingress TLS data from secrets --- autogen/gentemplates/gen.go | 13 +- docs/user-guide/kubernetes.md | 45 +++ .../kubernetes/builder_configuration_test.go | 49 +++ provider/kubernetes/builder_ingress_test.go | 29 +- provider/kubernetes/kubernetes.go | 50 +++ provider/kubernetes/kubernetes_test.go | 300 ++++++++++++++++++ templates/kubernetes.tmpl | 12 +- 7 files changed, 495 insertions(+), 3 deletions(-) diff --git a/autogen/gentemplates/gen.go b/autogen/gentemplates/gen.go index 57f9a58f3..6acb8d804 100644 --- a/autogen/gentemplates/gen.go +++ b/autogen/gentemplates/gen.go @@ -837,7 +837,18 @@ var _templatesKubernetesTmpl = []byte(`[backends]{{range $backendName, $backend [frontends."{{$frontendName}}".routes."{{$routeName}}"] rule = "{{$route.Rule}}" {{end}} -{{end}}`) +{{end}} + +{{range $tlsConfiguration := .TLSConfiguration}} +[[tlsConfiguration]] + entryPoints = [{{range $tlsConfiguration.EntryPoints}} + "{{.}}", + {{end}}] + [tlsConfiguration.certificate] + certFile = """{{$tlsConfiguration.Certificate.CertFile}}""" + keyFile = """{{$tlsConfiguration.Certificate.KeyFile}}""" +{{end}} +`) func templatesKubernetesTmplBytes() ([]byte, error) { return _templatesKubernetesTmpl, nil diff --git a/docs/user-guide/kubernetes.md b/docs/user-guide/kubernetes.md index b146515ed..2e9e2d045 100644 --- a/docs/user-guide/kubernetes.md +++ b/docs/user-guide/kubernetes.md @@ -333,6 +333,51 @@ echo "$(minikube ip) traefik-ui.minikube" | sudo tee -a /etc/hosts We should now be able to visit [traefik-ui.minikube](http://traefik-ui.minikube) in the browser and view the Træfik Web UI. +### Add a TLS Certificate to the Ingress + +!!! note + For this example to work you need a TLS entrypoint. You don't have to provide a TLS certificate at this point. For more details see [here](/configuration/entrypoints/). + +To setup an HTTPS-protected ingress, you can leverage the TLS feature of the ingress resource. + +```yaml +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: traefik-web-ui + namespace: kube-system + annotations: + kubernetes.io/ingress.class: traefik +spec: + rules: + - host: traefik-ui.minikube + http: + paths: + - backend: + serviceName: traefik-web-ui + servicePort: 80 + tls: + secretName: traefik-ui-tls-cert +``` + +In addition to the modified ingress you need to provide the TLS certificate via a kubernetes secret in the same namespace as the ingress. The following two commands will generate a new certificate and create a secret containing the key and cert files. + +```shell +openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout tls.key -out tls.crt -subj "/CN=traefik-ui.minikube" +kubectl -n kube-system create secret tls traefik-ui-tls-cert --key=tls.key --cert=tls.crt +``` + +If there are any errors while loading the TLS section of an ingress, the whole ingress will be skipped. + +!!! note + The secret must have two entries named `tls.key`and `tls.crt`. See the [kubernetes documentation](https://kubernetes.io/docs/concepts/services-networking/ingress/#tls) for more details. + +!!! note + The TLS certificates will be added to all entrypoints defined by the ingress annotation `traefik.frontend.entryPoints`. If no such annotation is provided, the TLS certificates will be added to all TLS-enabled `defaultEntryPoints`. + +!!! note + The field `hosts` in the TLS configuration is ignored. Instead, the domains provided by the certificate are used for this purpose. It is recommended to not use wildcard certificates as they will match globally. + ## Basic Authentication It's possible to add additional authentication annotations in the Ingress rule. diff --git a/provider/kubernetes/builder_configuration_test.go b/provider/kubernetes/builder_configuration_test.go index d12fd7132..f673b5e91 100644 --- a/provider/kubernetes/builder_configuration_test.go +++ b/provider/kubernetes/builder_configuration_test.go @@ -3,6 +3,7 @@ package kubernetes import ( "testing" + "github.com/containous/traefik/tls" "github.com/containous/traefik/types" "github.com/stretchr/testify/assert" ) @@ -201,6 +202,39 @@ func route(name string, rule string) func(*types.Route) string { } } +func tlsConfigurations(opts ...func(*tls.Configuration)) func(*types.Configuration) { + return func(c *types.Configuration) { + for _, opt := range opts { + tlsConf := &tls.Configuration{} + opt(tlsConf) + c.TLSConfiguration = append(c.TLSConfiguration, tlsConf) + } + } +} + +func tlsConfiguration(opts ...func(*tls.Configuration)) func(*tls.Configuration) { + return func(c *tls.Configuration) { + for _, opt := range opts { + opt(c) + } + } +} + +func tlsEntryPoints(entryPoints ...string) func(*tls.Configuration) { + return func(c *tls.Configuration) { + c.EntryPoints = entryPoints + } +} + +func certificate(cert string, key string) func(*tls.Configuration) { + return func(c *tls.Configuration) { + c.Certificate = &tls.Certificate{ + CertFile: tls.FileOrContent(cert), + KeyFile: tls.FileOrContent(key), + } + } +} + // Test func TestBuildConfiguration(t *testing.T) { @@ -247,6 +281,12 @@ func TestBuildConfiguration(t *testing.T) { ), ), ), + tlsConfigurations( + tlsConfiguration( + tlsEntryPoints("https"), + certificate("certificate", "key"), + ), + ), ) assert.EqualValues(t, sampleConfiguration(), actual) @@ -335,5 +375,14 @@ func sampleConfiguration() *types.Configuration { }, }, }, + TLSConfiguration: []*tls.Configuration{ + { + EntryPoints: []string{"https"}, + Certificate: &tls.Certificate{ + CertFile: tls.FileOrContent("certificate"), + KeyFile: tls.FileOrContent("key"), + }, + }, + }, } } diff --git a/provider/kubernetes/builder_ingress_test.go b/provider/kubernetes/builder_ingress_test.go index 5c54cdfbb..cccffd75d 100644 --- a/provider/kubernetes/builder_ingress_test.go +++ b/provider/kubernetes/builder_ingress_test.go @@ -92,6 +92,23 @@ func iBackend(name string, port intstr.IntOrString) func(*v1beta1.HTTPIngressPat } } +func iTLSes(opts ...func(*v1beta1.IngressTLS)) func(*v1beta1.Ingress) { + return func(i *v1beta1.Ingress) { + for _, opt := range opts { + iTLS := v1beta1.IngressTLS{} + opt(&iTLS) + i.Spec.TLS = append(i.Spec.TLS, iTLS) + } + } +} + +func iTLS(secret string, hosts ...string) func(*v1beta1.IngressTLS) { + return func(i *v1beta1.IngressTLS) { + i.SecretName = secret + i.Hosts = hosts + } +} + // Test func TestBuildIngress(t *testing.T) { @@ -107,7 +124,11 @@ func TestBuildIngress(t *testing.T) { onePath(iBackend("service2", intstr.FromInt(802))), ), ), - )) + ), + iTLSes( + iTLS("tls-secret", "foo"), + ), + ) assert.EqualValues(t, sampleIngress(), i) } @@ -164,6 +185,12 @@ func sampleIngress() *v1beta1.Ingress { }, }, }, + TLS: []v1beta1.IngressTLS{ + { + Hosts: []string{"foo"}, + SecretName: "tls-secret", + }, + }, }, } } diff --git a/provider/kubernetes/kubernetes.go b/provider/kubernetes/kubernetes.go index 5411663d4..0584870a5 100644 --- a/provider/kubernetes/kubernetes.go +++ b/provider/kubernetes/kubernetes.go @@ -19,6 +19,7 @@ import ( "github.com/containous/traefik/provider" "github.com/containous/traefik/provider/label" "github.com/containous/traefik/safe" + "github.com/containous/traefik/tls" "github.com/containous/traefik/types" "k8s.io/client-go/pkg/api/v1" "k8s.io/client-go/pkg/apis/extensions/v1beta1" @@ -174,6 +175,13 @@ func (p *Provider) loadIngresses(k8sClient Client) (*types.Configuration, error) continue } + tlsConfigs, err := getTLSConfigurations(i, k8sClient) + if err != nil { + log.Errorf("Error configuring TLS for ingress %s/%s: %v", i.Namespace, i.Name, err) + continue + } + templateObjects.TLSConfiguration = append(templateObjects.TLSConfiguration, tlsConfigs...) + for _, r := range i.Spec.Rules { if r.HTTP == nil { log.Warn("Error in ingress: HTTP is nil") @@ -441,6 +449,48 @@ func loadAuthCredentials(namespace, secretName string, k8sClient Client) ([]stri return creds, nil } +func getTLSConfigurations(ingress *v1beta1.Ingress, k8sClient Client) ([]*tls.Configuration, error) { + var tlsConfigs []*tls.Configuration + + for _, t := range ingress.Spec.TLS { + tlsSecret, exists, err := k8sClient.GetSecret(ingress.Namespace, t.SecretName) + if err != nil { + return nil, fmt.Errorf("failed to fetch secret %s/%s: %v", ingress.Namespace, t.SecretName, err) + } + if !exists { + return nil, fmt.Errorf("secret %s/%s does not exist", ingress.Namespace, t.SecretName) + } + + tlsCrtData, tlsCrtExists := tlsSecret.Data["tls.crt"] + tlsKeyData, tlsKeyExists := tlsSecret.Data["tls.key"] + + var missingEntries []string + if !tlsCrtExists { + missingEntries = append(missingEntries, "tls.crt") + } + if !tlsKeyExists { + missingEntries = append(missingEntries, "tls.key") + } + if len(missingEntries) > 0 { + return nil, fmt.Errorf("secret %s/%s is missing the following TLS data entries: %s", ingress.Namespace, t.SecretName, strings.Join(missingEntries, ", ")) + } + + entryPoints := label.GetSliceStringValue(ingress.Annotations, label.TraefikFrontendEntryPoints) + + tlsConfig := &tls.Configuration{ + EntryPoints: entryPoints, + Certificate: &tls.Certificate{ + CertFile: tls.FileOrContent(tlsCrtData), + KeyFile: tls.FileOrContent(tlsKeyData), + }, + } + + tlsConfigs = append(tlsConfigs, tlsConfig) + } + + return tlsConfigs, nil +} + func endpointPortNumber(servicePort v1.ServicePort, endpointPorts []v1.EndpointPort) int { if len(endpointPorts) > 0 { //name is optional if there is only one port diff --git a/provider/kubernetes/kubernetes_test.go b/provider/kubernetes/kubernetes_test.go index fe09c8c7d..3cfce6dba 100644 --- a/provider/kubernetes/kubernetes_test.go +++ b/provider/kubernetes/kubernetes_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/containous/traefik/provider/label" + "github.com/containous/traefik/tls" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/client-go/pkg/api/v1" @@ -1214,3 +1215,302 @@ func TestBasicAuthInTemplate(t *testing.T) { t.Fatalf("unexpected credentials: %+v", got) } } + +func TestTLSSecretLoad(t *testing.T) { + ingresses := []*v1beta1.Ingress{ + buildIngress( + iNamespace("testing"), + iAnnotation(label.TraefikFrontendEntryPoints, "ep1,ep2"), + iRules( + iRule(iHost("example.com"), iPaths( + onePath(iBackend("example-com", intstr.FromInt(80))), + )), + iRule(iHost("example.org"), iPaths( + onePath(iBackend("example-org", intstr.FromInt(80))), + )), + ), + iTLSes( + iTLS("myTlsSecret"), + ), + ), + buildIngress( + iNamespace("testing"), + iAnnotation(label.TraefikFrontendEntryPoints, "ep3"), + iRules( + iRule(iHost("example.fail"), iPaths( + onePath(iBackend("example-fail", intstr.FromInt(80))), + )), + ), + iTLSes( + iTLS("myUndefinedSecret"), + ), + ), + } + services := []*v1.Service{ + buildService( + sName("example-com"), + sNamespace("testing"), + sUID("1"), + sSpec( + clusterIP("10.0.0.1"), + sType("ClusterIP"), + sPorts(sPort(80, "http"))), + ), + buildService( + sName("example-org"), + sNamespace("testing"), + sUID("2"), + sSpec( + clusterIP("10.0.0.2"), + sType("ClusterIP"), + sPorts(sPort(80, "http"))), + ), + } + secrets := []*v1.Secret{ + { + ObjectMeta: v1.ObjectMeta{ + Name: "myTlsSecret", + UID: "1", + Namespace: "testing", + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"), + "tls.key": []byte("-----BEGIN PRIVATE KEY-----\n-----END PRIVATE KEY-----"), + }, + }, + } + endpoints := []*v1.Endpoints{} + watchChan := make(chan interface{}) + client := clientMock{ + ingresses: ingresses, + services: services, + secrets: secrets, + endpoints: endpoints, + watchChan: watchChan, + } + provider := Provider{} + actual, err := provider.loadIngresses(client) + if err != nil { + t.Fatalf("error %+v", err) + } + + expected := buildConfiguration( + backends( + backend("example.com", + servers(), + lbMethod("wrr"), + ), + backend("example.org", + servers(), + lbMethod("wrr"), + ), + ), + frontends( + frontend("example.com", + headers(), + entryPoints("ep1", "ep2"), + passHostHeader(), + routes( + route("example.com", "Host:example.com"), + ), + ), + frontend("example.org", + headers(), + entryPoints("ep1", "ep2"), + passHostHeader(), + routes( + route("example.org", "Host:example.org"), + ), + ), + ), + tlsConfigurations( + tlsConfiguration( + tlsEntryPoints("ep1", "ep2"), + certificate( + "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----", + "-----BEGIN PRIVATE KEY-----\n-----END PRIVATE KEY-----"), + ), + ), + ) + + assert.Equal(t, expected, actual) +} + +func TestGetTLSConfigurations(t *testing.T) { + testIngressWithoutHostname := buildIngress( + iNamespace("testing"), + iRules( + iRule(iHost("ep1.example.com")), + iRule(iHost("ep2.example.com")), + ), + iTLSes( + iTLS("test-secret"), + ), + ) + + tests := []struct { + desc string + ingress *v1beta1.Ingress + client Client + result []*tls.Configuration + errResult string + }{ + { + desc: "api client returns error", + ingress: testIngressWithoutHostname, + client: clientMock{ + apiSecretError: errors.New("api secret error"), + }, + errResult: "failed to fetch secret testing/test-secret: api secret error", + }, + { + desc: "api client doesn't find secret", + ingress: testIngressWithoutHostname, + client: clientMock{}, + errResult: "secret testing/test-secret does not exist", + }, + { + desc: "entry 'tls.crt' in secret missing", + ingress: testIngressWithoutHostname, + client: clientMock{ + secrets: []*v1.Secret{ + { + ObjectMeta: v1.ObjectMeta{ + Name: "test-secret", + Namespace: "testing", + }, + Data: map[string][]byte{ + "tls.key": []byte("tls-key"), + }, + }, + }, + }, + errResult: "secret testing/test-secret is missing the following TLS data entries: tls.crt", + }, + { + desc: "entry 'tls.key' in secret missing", + ingress: testIngressWithoutHostname, + client: clientMock{ + secrets: []*v1.Secret{ + { + ObjectMeta: v1.ObjectMeta{ + Name: "test-secret", + Namespace: "testing", + }, + Data: map[string][]byte{ + "tls.crt": []byte("tls-crt"), + }, + }, + }, + }, + errResult: "secret testing/test-secret is missing the following TLS data entries: tls.key", + }, + { + desc: "secret doesn't provide any of the required fields", + ingress: testIngressWithoutHostname, + client: clientMock{ + secrets: []*v1.Secret{ + { + ObjectMeta: v1.ObjectMeta{ + Name: "test-secret", + Namespace: "testing", + }, + Data: map[string][]byte{}, + }, + }, + }, + errResult: "secret testing/test-secret is missing the following TLS data entries: tls.crt, tls.key", + }, + { + desc: "add certificates to the configuration", + ingress: buildIngress( + iNamespace("testing"), + iRules( + iRule(iHost("ep1.example.com")), + iRule(iHost("ep2.example.com")), + iRule(iHost("ep3.example.com")), + ), + iTLSes( + iTLS("test-secret"), + iTLS("test-secret"), + ), + ), + client: clientMock{ + secrets: []*v1.Secret{ + { + ObjectMeta: v1.ObjectMeta{ + Name: "test-secret", + Namespace: "testing", + }, + Data: map[string][]byte{ + "tls.crt": []byte("tls-crt"), + "tls.key": []byte("tls-key"), + }, + }, + }, + }, + result: []*tls.Configuration{ + { + Certificate: &tls.Certificate{ + CertFile: tls.FileOrContent("tls-crt"), + KeyFile: tls.FileOrContent("tls-key"), + }, + }, + { + Certificate: &tls.Certificate{ + CertFile: tls.FileOrContent("tls-crt"), + KeyFile: tls.FileOrContent("tls-key"), + }, + }, + }, + }, + { + desc: "pass the endpoints defined in the annotation to the certificate", + ingress: buildIngress( + iNamespace("testing"), + iAnnotation(label.TraefikFrontendEntryPoints, "https,api-secure"), + iRules(iRule(iHost("example.com"))), + iTLSes(iTLS("test-secret")), + ), + client: clientMock{ + secrets: []*v1.Secret{ + { + ObjectMeta: v1.ObjectMeta{ + Name: "test-secret", + Namespace: "testing", + }, + Data: map[string][]byte{ + "tls.crt": []byte("tls-crt"), + "tls.key": []byte("tls-key"), + }, + }, + }, + }, + result: []*tls.Configuration{ + { + EntryPoints: []string{"https", "api-secure"}, + Certificate: &tls.Certificate{ + CertFile: tls.FileOrContent("tls-crt"), + KeyFile: tls.FileOrContent("tls-key"), + }, + }, + }, + }, + } + + for _, test := range tests { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + tlsConfigs, err := getTLSConfigurations(test.ingress, test.client) + + if test.errResult != "" { + assert.EqualError(t, err, test.errResult) + } else { + assert.Nil(t, err) + assert.Equal(t, test.result, tlsConfigs) + } + }) + } +} diff --git a/templates/kubernetes.tmpl b/templates/kubernetes.tmpl index 86d3f4612..f848800e4 100644 --- a/templates/kubernetes.tmpl +++ b/templates/kubernetes.tmpl @@ -92,4 +92,14 @@ [frontends."{{$frontendName}}".routes."{{$routeName}}"] rule = "{{$route.Rule}}" {{end}} -{{end}} \ No newline at end of file +{{end}} + +{{range $tlsConfiguration := .TLSConfiguration}} +[[tlsConfiguration]] + entryPoints = [{{range $tlsConfiguration.EntryPoints}} + "{{.}}", + {{end}}] + [tlsConfiguration.certificate] + certFile = """{{$tlsConfiguration.Certificate.CertFile}}""" + keyFile = """{{$tlsConfiguration.Certificate.KeyFile}}""" +{{end}}