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).
### 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
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:
- `id` : string
- The id is the primary key.
- `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/)
## 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
is of the ExternalName type.
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 is of the ExternalName type.
### Disable entirely
Add the following to your toml config:
```toml
disablePassHostHeaders = true
```
### 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:
```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
request with the Host header being static.otherdomain.com.
Note: The per ingress annotation overides whatever the global value is set to. So you
could set `disablePassHostHeaders` to true in your toml file and then enable passing
Note: The per ingress annotation overides whatever the global value is set to.
So you could set `disablePassHostHeaders` to `true` in your toml file and then enable passing
the host header per ingress if you wanted.
## Excluding an ingress from Træfik
You can control which ingress Træfik cares about by using the `kubernetes.io/ingress.class`
annotation. By default if the annotation is not set at all Træfik will include the
ingress. If the annotation is set to anything other than traefik or a blank string
Træfik will ignore it.
You can control which ingress Træfik cares about by using the `kubernetes.io/ingress.class` annotation.
By default if the annotation is not set at all Træfik will include the ingress.
If the annotation is set to anything other than traefik or a blank string Træfik will ignore it.
![](http://i.giphy.com/ujUdrdpX7Ok5W.gif)

View file

@ -26,6 +26,7 @@ const resyncPeriod = time.Minute * 5
type Client interface {
GetIngresses(namespaces Namespaces) []*v1beta1.Ingress
GetService(namespace, name string) (*v1.Service, bool, error)
GetSecret(namespace, name string) (*v1.Secret, bool, error)
GetEndpoints(namespace, name string) (*v1.Endpoints, bool, error)
WatchAll(labelSelector string, stopCh <-chan struct{}) (<-chan interface{}, error)
}
@ -34,10 +35,12 @@ type clientImpl struct {
ingController *cache.Controller
svcController *cache.Controller
epController *cache.Controller
secController *cache.Controller
ingStore cache.Store
svcStore cache.Store
epStore cache.Store
secStore cache.Store
clientset *kubernetes.Clientset
}
@ -154,6 +157,16 @@ func (c *clientImpl) GetService(namespace, name string) (*v1.Service, bool, erro
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
func (c *clientImpl) WatchServices(watchCh chan<- interface{}, stopCh <-chan struct{}) {
source := cache.NewListWatchFromClient(
@ -199,6 +212,21 @@ func (c *clientImpl) WatchEndpoints(watchCh chan<- interface{}, stopCh <-chan st
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
// Filters ingresses by labelSelector
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.WatchServices(eventCh, stopCh)
c.WatchEndpoints(eventCh, stopCh)
c.WatchSecrets(eventCh, stopCh)
go func() {
defer close(watchCh)

View file

@ -1,6 +1,9 @@
package kubernetes
import (
"bufio"
"bytes"
"errors"
"fmt"
"os"
"reflect"
@ -16,6 +19,7 @@ import (
"github.com/containous/traefik/safe"
"github.com/containous/traefik/types"
"k8s.io/client-go/pkg/api/v1"
"k8s.io/client-go/pkg/apis/extensions/v1beta1"
"k8s.io/client-go/pkg/util/intstr"
)
@ -29,6 +33,8 @@ const (
ruleTypePathPrefix = "PathPrefix"
)
const traefikDefaultRealm = "traefik"
// Provider holds configurations of the provider.
type Provider struct {
provider.BaseProvider `mapstructure:",squash"`
@ -159,13 +165,21 @@ func (p *Provider) loadIngresses(k8sClient Client) (*types.Configuration, error)
default:
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 {
basicAuthCreds, err := handleBasicAuthConfig(i, k8sClient)
if err != nil {
return nil, err
}
templateObjects.Frontends[r.Host+pa.Path] = &types.Frontend{
Backend: r.Host + pa.Path,
PassHostHeader: PassHostHeader,
Routes: make(map[string]types.Route),
Priority: len(pa.Path),
BasicAuth: basicAuthCreds,
}
}
if len(r.Host) > 0 {
@ -278,6 +292,56 @@ func (p *Provider) loadIngresses(k8sClient Client) (*types.Configuration, error)
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 {
if len(endpointPorts) > 0 {
//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{
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{}
watchChan := make(chan interface{})
client := clientMock{
ingresses: ingresses,
services: services,
secrets: secrets,
endpoints: endpoints,
watchChan: watchChan,
}
@ -1558,6 +1600,19 @@ func TestIngressAnnotations(t *testing.T) {
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{
"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 {
ingresses []*v1beta1.Ingress
services []*v1.Service
secrets []*v1.Secret
endpoints []*v1.Endpoints
watchChan chan interface{}
apiServiceError error
apiSecretError error
apiEndpointsError error
}
@ -2064,6 +2222,19 @@ func (c clientMock) GetService(namespace, name string) (*v1.Service, bool, error
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) {
if c.apiEndpointsError != nil {
return nil, false, c.apiEndpointsError

View file

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