Merge pull request #1488 from alpe/k8s-auth

Add basic auth to kubernetes provider
This commit is contained in:
Ludovic Fernandez 2017-05-03 13:37:05 +02:00 committed by GitHub
commit b5283391dd
6 changed files with 296 additions and 12 deletions

View file

@ -1186,6 +1186,22 @@ Additionally, an annotation can be used on Kubernetes services to set the [circu
- `traefik.backend.circuitbreaker: <expression>`: set the circuit breaker expression for the backend (Default: nil). - `traefik.backend.circuitbreaker: <expression>`: set the circuit breaker expression for the backend (Default: nil).
### Authentication
Is possible to add additional authentication annotations in the Ingress rule.
The source of the authentication is a secret that contains usernames and passwords inside the the key auth.
- `ingress.kubernetes.io/auth-type`: `basic`
- `ingress.kubernetes.io/auth-secret`: contains the usernames and passwords with access to the paths defined in the Ingress Rule.
The secret must be created in the same namespace as the Ingress rule.
Limitations:
- Basic authentication only.
- Realm not configurable; only `traefik` default.
- Secret must contain only single file.
## Consul backend ## Consul backend
Træfik can be configured to use Consul as a backend configuration: Træfik can be configured to use Consul as a backend configuration:
@ -1719,7 +1735,6 @@ RefreshSeconds = 15
Items in the `dynamodb` table must have three attributes: Items in the `dynamodb` table must have three attributes:
- `id` : string - `id` : string
- The id is the primary key. - The id is the primary key.
- `name` : string - `name` : string

View file

@ -505,19 +505,22 @@ You should now be able to visit the websites in your browser.
* [cheeses.local/wensleydale](http://cheeses.local/wensleydale/) * [cheeses.local/wensleydale](http://cheeses.local/wensleydale/)
## Disable passing the Host header ## Disable passing the Host header
By default Træfik will pass the incoming Host header on to the upstream resource. There
are times however where you may not want this to be the case. For example if your service By default Træfik will pass the incoming Host header on to the upstream resource.
is of the ExternalName type. There are times however where you may not want this to be the case.
For example if your service is of the ExternalName type.
### Disable entirely ### Disable entirely
Add the following to your toml config: Add the following to your toml config:
```toml ```toml
disablePassHostHeaders = true disablePassHostHeaders = true
``` ```
### Disable per ingress ### Disable per ingress
To disable passing the Host header per ingress resource set the "traefik.frontend.passHostHeader"
annotation on your ingress to "false". To disable passing the Host header per ingress resource set the `traefik.frontend.passHostHeader`
annotation on your ingress to `false`.
Here is an example ingress definition: Here is an example ingress definition:
```yaml ```yaml
@ -557,16 +560,15 @@ If you were to visit example.com/static the request would then be passed onto
static.otherdomain.com/static and static.otherdomain.com would receive the static.otherdomain.com/static and static.otherdomain.com would receive the
request with the Host header being static.otherdomain.com. request with the Host header being static.otherdomain.com.
Note: The per ingress annotation overides whatever the global value is set to. So you Note: The per ingress annotation overides whatever the global value is set to.
could set `disablePassHostHeaders` to true in your toml file and then enable passing So you could set `disablePassHostHeaders` to `true` in your toml file and then enable passing
the host header per ingress if you wanted. the host header per ingress if you wanted.
## Excluding an ingress from Træfik ## Excluding an ingress from Træfik
You can control which ingress Træfik cares about by using the `kubernetes.io/ingress.class` You can control which ingress Træfik cares about by using the `kubernetes.io/ingress.class` annotation.
annotation. By default if the annotation is not set at all Træfik will include the By default if the annotation is not set at all Træfik will include the ingress.
ingress. If the annotation is set to anything other than traefik or a blank string If the annotation is set to anything other than traefik or a blank string Træfik will ignore it.
Træfik will ignore it.
![](http://i.giphy.com/ujUdrdpX7Ok5W.gif) ![](http://i.giphy.com/ujUdrdpX7Ok5W.gif)

View file

@ -26,6 +26,7 @@ const resyncPeriod = time.Minute * 5
type Client interface { type Client interface {
GetIngresses(namespaces Namespaces) []*v1beta1.Ingress GetIngresses(namespaces Namespaces) []*v1beta1.Ingress
GetService(namespace, name string) (*v1.Service, bool, error) GetService(namespace, name string) (*v1.Service, bool, error)
GetSecret(namespace, name string) (*v1.Secret, bool, error)
GetEndpoints(namespace, name string) (*v1.Endpoints, bool, error) GetEndpoints(namespace, name string) (*v1.Endpoints, bool, error)
WatchAll(labelSelector string, stopCh <-chan struct{}) (<-chan interface{}, error) WatchAll(labelSelector string, stopCh <-chan struct{}) (<-chan interface{}, error)
} }
@ -34,10 +35,12 @@ type clientImpl struct {
ingController *cache.Controller ingController *cache.Controller
svcController *cache.Controller svcController *cache.Controller
epController *cache.Controller epController *cache.Controller
secController *cache.Controller
ingStore cache.Store ingStore cache.Store
svcStore cache.Store svcStore cache.Store
epStore cache.Store epStore cache.Store
secStore cache.Store
clientset *kubernetes.Clientset clientset *kubernetes.Clientset
} }
@ -154,6 +157,16 @@ func (c *clientImpl) GetService(namespace, name string) (*v1.Service, bool, erro
return service, exists, err return service, exists, err
} }
func (c *clientImpl) GetSecret(namespace, name string) (*v1.Secret, bool, error) {
var secret *v1.Secret
item, exists, err := c.secStore.GetByKey(namespace + "/" + name)
if err == nil && item != nil {
secret = item.(*v1.Secret)
}
return secret, exists, err
}
// WatchServices starts the watch of Provider Service resources and updates the corresponding store // WatchServices starts the watch of Provider Service resources and updates the corresponding store
func (c *clientImpl) WatchServices(watchCh chan<- interface{}, stopCh <-chan struct{}) { func (c *clientImpl) WatchServices(watchCh chan<- interface{}, stopCh <-chan struct{}) {
source := cache.NewListWatchFromClient( source := cache.NewListWatchFromClient(
@ -199,6 +212,21 @@ func (c *clientImpl) WatchEndpoints(watchCh chan<- interface{}, stopCh <-chan st
go c.epController.Run(stopCh) go c.epController.Run(stopCh)
} }
func (c *clientImpl) WatchSecrets(watchCh chan<- interface{}, stopCh <-chan struct{}) {
source := cache.NewListWatchFromClient(
c.clientset.CoreV1().RESTClient(),
"secrets",
api.NamespaceAll,
fields.Everything())
c.secStore, c.secController = cache.NewInformer(
source,
&v1.Endpoints{},
resyncPeriod,
newResourceEventHandlerFuncs(watchCh))
go c.secController.Run(stopCh)
}
// WatchAll returns events in the cluster and updates the stores via informer // WatchAll returns events in the cluster and updates the stores via informer
// Filters ingresses by labelSelector // Filters ingresses by labelSelector
func (c *clientImpl) WatchAll(labelSelector string, stopCh <-chan struct{}) (<-chan interface{}, error) { func (c *clientImpl) WatchAll(labelSelector string, stopCh <-chan struct{}) (<-chan interface{}, error) {
@ -213,6 +241,7 @@ func (c *clientImpl) WatchAll(labelSelector string, stopCh <-chan struct{}) (<-c
c.WatchIngresses(kubeLabelSelector, eventCh, stopCh) c.WatchIngresses(kubeLabelSelector, eventCh, stopCh)
c.WatchServices(eventCh, stopCh) c.WatchServices(eventCh, stopCh)
c.WatchEndpoints(eventCh, stopCh) c.WatchEndpoints(eventCh, stopCh)
c.WatchSecrets(eventCh, stopCh)
go func() { go func() {
defer close(watchCh) defer close(watchCh)

View file

@ -1,6 +1,9 @@
package kubernetes package kubernetes
import ( import (
"bufio"
"bytes"
"errors"
"fmt" "fmt"
"os" "os"
"reflect" "reflect"
@ -16,6 +19,7 @@ import (
"github.com/containous/traefik/safe" "github.com/containous/traefik/safe"
"github.com/containous/traefik/types" "github.com/containous/traefik/types"
"k8s.io/client-go/pkg/api/v1" "k8s.io/client-go/pkg/api/v1"
"k8s.io/client-go/pkg/apis/extensions/v1beta1"
"k8s.io/client-go/pkg/util/intstr" "k8s.io/client-go/pkg/util/intstr"
) )
@ -29,6 +33,8 @@ const (
ruleTypePathPrefix = "PathPrefix" ruleTypePathPrefix = "PathPrefix"
) )
const traefikDefaultRealm = "traefik"
// Provider holds configurations of the provider. // Provider holds configurations of the provider.
type Provider struct { type Provider struct {
provider.BaseProvider `mapstructure:",squash"` provider.BaseProvider `mapstructure:",squash"`
@ -159,13 +165,21 @@ func (p *Provider) loadIngresses(k8sClient Client) (*types.Configuration, error)
default: default:
log.Warnf("Unknown value of %s for traefik.frontend.passHostHeader, falling back to %s", passHostHeaderAnnotation, PassHostHeader) log.Warnf("Unknown value of %s for traefik.frontend.passHostHeader, falling back to %s", passHostHeaderAnnotation, PassHostHeader)
} }
if realm := i.Annotations["ingress.kubernetes.io/auth-realm"]; realm != "" && realm != traefikDefaultRealm {
return nil, errors.New("no realm customization supported")
}
if _, exists := templateObjects.Frontends[r.Host+pa.Path]; !exists { if _, exists := templateObjects.Frontends[r.Host+pa.Path]; !exists {
basicAuthCreds, err := handleBasicAuthConfig(i, k8sClient)
if err != nil {
return nil, err
}
templateObjects.Frontends[r.Host+pa.Path] = &types.Frontend{ templateObjects.Frontends[r.Host+pa.Path] = &types.Frontend{
Backend: r.Host + pa.Path, Backend: r.Host + pa.Path,
PassHostHeader: PassHostHeader, PassHostHeader: PassHostHeader,
Routes: make(map[string]types.Route), Routes: make(map[string]types.Route),
Priority: len(pa.Path), Priority: len(pa.Path),
BasicAuth: basicAuthCreds,
} }
} }
if len(r.Host) > 0 { if len(r.Host) > 0 {
@ -278,6 +292,56 @@ func (p *Provider) loadIngresses(k8sClient Client) (*types.Configuration, error)
return &templateObjects, nil return &templateObjects, nil
} }
func handleBasicAuthConfig(i *v1beta1.Ingress, k8sClient Client) ([]string, error) {
authType, exists := i.Annotations["ingress.kubernetes.io/auth-type"]
if !exists {
return nil, nil
}
if strings.ToLower(authType) != "basic" {
return nil, fmt.Errorf("unsupported auth-type: %q", authType)
}
authSecret := i.Annotations["ingress.kubernetes.io/auth-secret"]
if authSecret == "" {
return nil, errors.New("auth-secret annotation must be set")
}
basicAuthCreds, err := loadAuthCredentials(i.Namespace, authSecret, k8sClient)
if err != nil {
return nil, err
}
if len(basicAuthCreds) == 0 {
return nil, errors.New("secret file without credentials")
}
return basicAuthCreds, nil
}
func loadAuthCredentials(namespace, secretName string, k8sClient Client) ([]string, error) {
secret, ok, err := k8sClient.GetSecret(namespace, secretName)
switch { // keep order of case conditions
case err != nil:
return nil, fmt.Errorf("failed to fetch secret %q/%q: %s", namespace, secretName, err)
case !ok:
return nil, fmt.Errorf("secret %q/%q not found", namespace, secretName)
case secret == nil:
return nil, errors.New("secret data must not be nil")
case len(secret.Data) != 1:
return nil, errors.New("secret must contain single element only")
default:
}
var firstSecret []byte
for _, v := range secret.Data {
firstSecret = v
break
}
creds := make([]string, 0)
scanner := bufio.NewScanner(bytes.NewReader(firstSecret))
for scanner.Scan() {
if cred := scanner.Text(); cred != "" {
creds = append(creds, cred)
}
}
return creds, nil
}
func endpointPortNumber(servicePort v1.ServicePort, endpointPorts []v1.EndpointPort) int { func endpointPortNumber(servicePort v1.ServicePort, endpointPorts []v1.EndpointPort) int {
if len(endpointPorts) > 0 { if len(endpointPorts) > 0 {
//name is optional if there is only one port //name is optional if there is only one port

View file

@ -1466,6 +1466,35 @@ func TestIngressAnnotations(t *testing.T) {
}, },
}, },
}, },
{
ObjectMeta: v1.ObjectMeta{
Namespace: "testing",
Annotations: map[string]string{
"ingress.kubernetes.io/auth-type": "basic",
"ingress.kubernetes.io/auth-secret": "mySecret",
},
},
Spec: v1beta1.IngressSpec{
Rules: []v1beta1.IngressRule{
{
Host: "basic",
IngressRuleValue: v1beta1.IngressRuleValue{
HTTP: &v1beta1.HTTPIngressRuleValue{
Paths: []v1beta1.HTTPIngressPath{
{
Path: "/auth",
Backend: v1beta1.IngressBackend{
ServiceName: "service1",
ServicePort: intstr.FromInt(80),
},
},
},
},
},
},
},
},
},
{ {
ObjectMeta: v1.ObjectMeta{ ObjectMeta: v1.ObjectMeta{
Namespace: "testing", Namespace: "testing",
@ -1515,12 +1544,25 @@ func TestIngressAnnotations(t *testing.T) {
}, },
}, },
} }
secrets := []*v1.Secret{
{
ObjectMeta: v1.ObjectMeta{
Name: "mySecret",
UID: "1",
Namespace: "testing",
},
Data: map[string][]byte{
"auth": []byte("myUser:myEncodedPW"),
},
},
}
endpoints := []*v1.Endpoints{} endpoints := []*v1.Endpoints{}
watchChan := make(chan interface{}) watchChan := make(chan interface{})
client := clientMock{ client := clientMock{
ingresses: ingresses, ingresses: ingresses,
services: services, services: services,
secrets: secrets,
endpoints: endpoints, endpoints: endpoints,
watchChan: watchChan, watchChan: watchChan,
} }
@ -1558,6 +1600,19 @@ func TestIngressAnnotations(t *testing.T) {
Method: "wrr", Method: "wrr",
}, },
}, },
"basic/auth": {
Servers: map[string]types.Server{
"http://example.com": {
URL: "http://example.com",
Weight: 1,
},
},
CircuitBreaker: nil,
LoadBalancer: &types.LoadBalancer{
Sticky: false,
Method: "wrr",
},
},
}, },
Frontends: map[string]*types.Frontend{ Frontends: map[string]*types.Frontend{
"foo/bar": { "foo/bar": {
@ -1586,6 +1641,20 @@ func TestIngressAnnotations(t *testing.T) {
}, },
}, },
}, },
"basic/auth": {
Backend: "basic/auth",
PassHostHeader: true,
Priority: len("/auth"),
Routes: map[string]types.Route{
"/auth": {
Rule: "PathPrefix:/auth",
},
"basic": {
Rule: "Host:basic",
},
},
BasicAuth: []string{"myUser:myEncodedPW"},
},
}, },
} }
@ -2030,13 +2099,102 @@ func TestMissingResources(t *testing.T) {
} }
} }
func TestBasicAuthInTemplate(t *testing.T) {
ingresses := []*v1beta1.Ingress{
{
ObjectMeta: v1.ObjectMeta{
Namespace: "testing",
Annotations: map[string]string{
"ingress.kubernetes.io/auth-type": "basic",
"ingress.kubernetes.io/auth-secret": "mySecret",
},
},
Spec: v1beta1.IngressSpec{
Rules: []v1beta1.IngressRule{
{
Host: "basic",
IngressRuleValue: v1beta1.IngressRuleValue{
HTTP: &v1beta1.HTTPIngressRuleValue{
Paths: []v1beta1.HTTPIngressPath{
{
Path: "/auth",
Backend: v1beta1.IngressBackend{
ServiceName: "service1",
ServicePort: intstr.FromInt(80),
},
},
},
},
},
},
},
},
},
}
services := []*v1.Service{
{
ObjectMeta: v1.ObjectMeta{
Name: "service1",
UID: "1",
Namespace: "testing",
},
Spec: v1.ServiceSpec{
ClusterIP: "10.0.0.1",
Type: "ExternalName",
ExternalName: "example.com",
Ports: []v1.ServicePort{
{
Name: "http",
Port: 80,
},
},
},
},
}
secrets := []*v1.Secret{
{
ObjectMeta: v1.ObjectMeta{
Name: "mySecret",
UID: "1",
Namespace: "testing",
},
Data: map[string][]byte{
"auth": []byte("myUser:myEncodedPW"),
},
},
}
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)
}
actual = provider.loadConfig(*actual)
got := actual.Frontends["basic/auth"].BasicAuth
if !reflect.DeepEqual(got, []string{"myUser:myEncodedPW"}) {
t.Fatalf("unexpected credentials: %+v", got)
}
}
type clientMock struct { type clientMock struct {
ingresses []*v1beta1.Ingress ingresses []*v1beta1.Ingress
services []*v1.Service services []*v1.Service
secrets []*v1.Secret
endpoints []*v1.Endpoints endpoints []*v1.Endpoints
watchChan chan interface{} watchChan chan interface{}
apiServiceError error apiServiceError error
apiSecretError error
apiEndpointsError error apiEndpointsError error
} }
@ -2064,6 +2222,19 @@ func (c clientMock) GetService(namespace, name string) (*v1.Service, bool, error
return nil, false, nil return nil, false, nil
} }
func (c clientMock) GetSecret(namespace, name string) (*v1.Secret, bool, error) {
if c.apiSecretError != nil {
return nil, false, c.apiSecretError
}
for _, secret := range c.secrets {
if secret.Namespace == namespace && secret.Name == name {
return secret, true, nil
}
}
return nil, false, nil
}
func (c clientMock) GetEndpoints(namespace, name string) (*v1.Endpoints, bool, error) { func (c clientMock) GetEndpoints(namespace, name string) (*v1.Endpoints, bool, error) {
if c.apiEndpointsError != nil { if c.apiEndpointsError != nil {
return nil, false, c.apiEndpointsError return nil, false, c.apiEndpointsError

View file

@ -20,6 +20,9 @@
backend = "{{$frontend.Backend}}" backend = "{{$frontend.Backend}}"
priority = {{$frontend.Priority}} priority = {{$frontend.Priority}}
passHostHeader = {{$frontend.PassHostHeader}} passHostHeader = {{$frontend.PassHostHeader}}
basicAuth = [{{range $frontend.BasicAuth}}
"{{.}}",
{{end}}]
{{range $routeName, $route := $frontend.Routes}} {{range $routeName, $route := $frontend.Routes}}
[frontends."{{$frontendName}}".routes."{{$routeName}}"] [frontends."{{$frontendName}}".routes."{{$routeName}}"]
rule = "{{$route.Rule}}" rule = "{{$route.Rule}}"