Skip to content

Commit 6ecb769

Browse files
authored
add ingress basic-auth secret generator (#26)
* add ingress basic-auth secret generator * add docs for basic-auth generator * fix ci * fix copy-and-paste
1 parent cf0e8b2 commit 6ecb769

File tree

8 files changed

+287
-13
lines changed

8 files changed

+287
-13
lines changed

.github/workflows/workflow.yml

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,18 @@ on:
1111

1212
env:
1313
KUBECONFIG: /tmp/kubeconfig
14-
OPERATOR_SDK_VERSION: v0.16.0
14+
OPERATOR_SDK_VERSION: v0.18.2
1515
IMAGE_NAME: quay.io/mittwald/kubernetes-secret-generator
1616

1717
jobs:
1818
test:
1919
name: Test
2020
runs-on: ubuntu-latest
2121
steps:
22-
- name: Set up Go 1.13
22+
- name: Set up Go 1.15
2323
uses: actions/setup-go@v1
2424
with:
25-
go-version: 1.13
25+
go-version: 1.15
2626
id: go
2727

2828
- name: Check out code into the Go module directory
@@ -59,10 +59,10 @@ jobs:
5959
name: Build Image
6060
runs-on: ubuntu-latest
6161
steps:
62-
- name: Set up Go 1.13
62+
- name: Set up Go 1.15
6363
uses: actions/setup-go@v1
6464
with:
65-
go-version: 1.13
65+
go-version: 1.15
6666
id: go
6767

6868
- name: Check out code into the Go module directory
@@ -80,10 +80,10 @@ jobs:
8080
needs: ['test', 'build']
8181
if: github.ref == 'refs/heads/master'
8282
steps:
83-
- name: Set up Go 1.13
83+
- name: Set up Go 1.15
8484
uses: actions/setup-go@v1
8585
with:
86-
go-version: 1.13
86+
go-version: 1.15
8787
id: go
8888

8989
- name: Registry Login
@@ -107,10 +107,10 @@ jobs:
107107
needs: ['test', 'build']
108108
if: startsWith(github.ref, 'refs/tags/v')
109109
steps:
110-
- name: Set up Go 1.13
110+
- name: Set up Go 1.15
111111
uses: actions/setup-go@v1
112112
with:
113-
go-version: 1.13
113+
go-version: 1.15
114114
id: go
115115

116116
- name: Registry Login

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ fmt:
3434
.PHONY: kind
3535
kind: ## Create a kind cluster to test against
3636
kind create cluster --name kind-k8s-secret-generator
37-
kind get kubeconfig --internal --name kind-k8s-secret-generator | tee ${KUBECONFIG}
37+
kind get kubeconfig --name kind-k8s-secret-generator | tee ${KUBECONFIG}
3838

3939
.PHONY: build
4040
build:

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,41 @@ data:
129129
ssh-privatekey: LS0tLS1CRUdJTi...
130130
```
131131

132+
### Ingress Basic Auth
133+
134+
To generate Ingress Basic Auth credentials, the `secret-generator.v1.mittwald.de/type` annotation **has** to be present on the kubernetes secret object.
135+
136+
The operator will then add three keys to the secret object.
137+
The ingress will interpret the `auth` key as a htpasswd entry. This entry contains the username, and the hashed generated password for the user.
138+
The operator also stores the username and cleartext password in the `username` and `password` keys.
139+
140+
If a username other than `admin` is desired, it can be specified using the `secret-generator.v1.mittwald.de/basic-auth-username` annotation.
141+
142+
```yaml
143+
apiVersion: v1
144+
kind: Secret
145+
metadata:
146+
annotations:
147+
secret-generator.v1.mittwald.de/type: basic-auth
148+
data: {}
149+
```
150+
151+
after reconciliation:
152+
153+
```yaml
154+
apiVersion: v1
155+
kind: Secret
156+
metadata:
157+
annotations:
158+
secret-generator.v1.mittwald.de/type: basic-auth
159+
secret-generator.v1.mittwald.de/autogenerate-generated-at: "2020-04-03T14:07:47+02:00"
160+
type: Opaque
161+
data:
162+
username: admin
163+
password: test123
164+
auth: "admin:PASSWORD_HASH"
165+
```
166+
132167
## Operational tasks
133168

134169
- Regenerate all automatically generated secrets:
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package secret
2+
3+
import (
4+
"github.com/go-logr/logr"
5+
corev1 "k8s.io/api/core/v1"
6+
"sigs.k8s.io/controller-runtime/pkg/reconcile"
7+
"time"
8+
9+
"golang.org/x/crypto/bcrypt"
10+
)
11+
12+
// Ingress basic auth secret field
13+
const SecretFieldBasicAuthIngress = "auth"
14+
const SecretFieldBasicAuthUsername = "username"
15+
const SecretFieldBasicAuthPassword = "password"
16+
17+
type BasicAuthGenerator struct {
18+
log logr.Logger
19+
}
20+
21+
func (bg BasicAuthGenerator) generateData(instance *corev1.Secret) (reconcile.Result, error) {
22+
existingAuth := string(instance.Data[SecretFieldBasicAuthIngress])
23+
24+
regenerate := instance.Annotations[AnnotationSecretRegenerate] != ""
25+
26+
if len(existingAuth) > 0 && !regenerate {
27+
return reconcile.Result{}, nil
28+
}
29+
delete(instance.Annotations, AnnotationSecretRegenerate)
30+
31+
// if no username is given, fall back to "admin"
32+
username := instance.Annotations[AnnotationBasicAuthUsername]
33+
if username == "" {
34+
username = "admin"
35+
}
36+
37+
length, err := secretLengthFromAnnotation(secretLength(), instance.Annotations)
38+
if err != nil {
39+
return reconcile.Result{}, err
40+
}
41+
42+
password, err := generateRandomString(length)
43+
if err != nil {
44+
bg.log.Error(err, "could not generate new random string")
45+
return reconcile.Result{RequeueAfter: time.Second * 30}, err
46+
}
47+
48+
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
49+
if err != nil {
50+
bg.log.Error(err, "could not hash random string")
51+
return reconcile.Result{RequeueAfter: time.Second * 30}, err
52+
}
53+
54+
instance.Data[SecretFieldBasicAuthIngress] = append([]byte(username+":"), passwordHash...)
55+
instance.Data[SecretFieldBasicAuthUsername] = []byte(username)
56+
instance.Data[SecretFieldBasicAuthPassword] = []byte(password)
57+
return reconcile.Result{}, nil
58+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package secret
2+
3+
import (
4+
"context"
5+
"github.com/imdario/mergo"
6+
"github.com/stretchr/testify/require"
7+
corev1 "k8s.io/api/core/v1"
8+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
9+
"sigs.k8s.io/controller-runtime/pkg/client"
10+
"strings"
11+
"testing"
12+
)
13+
14+
func newBasicAuthTestSecret(extraAnnotations map[string]string) *corev1.Secret {
15+
annotations := map[string]string{
16+
AnnotationSecretType: string(SecretTypeBasicAuth),
17+
}
18+
19+
if extraAnnotations != nil {
20+
if err := mergo.Merge(&annotations, extraAnnotations, mergo.WithOverride); err != nil {
21+
panic(err)
22+
}
23+
}
24+
25+
s := &corev1.Secret{
26+
ObjectMeta: metav1.ObjectMeta{
27+
Name: getSecretName(),
28+
Namespace: "default",
29+
Labels: map[string]string{
30+
labelSecretGeneratorTest: "yes",
31+
},
32+
Annotations: annotations,
33+
},
34+
Type: corev1.SecretTypeOpaque,
35+
Data: map[string][]byte{},
36+
}
37+
38+
return s
39+
}
40+
41+
// verify basic fields of the secret are present
42+
func verifyBasicAuthSecret(t *testing.T, in, out *corev1.Secret) {
43+
if out.Annotations[AnnotationSecretType] != string(SecretTypeBasicAuth) {
44+
t.Errorf("generated secret has wrong type %s on %s annotation", out.Annotations[AnnotationSecretType], AnnotationSecretType)
45+
}
46+
47+
_, wasGenerated := in.Annotations[AnnotationSecretAutoGeneratedAt]
48+
49+
auth := out.Data[SecretFieldBasicAuthIngress]
50+
password := out.Data[SecretFieldBasicAuthPassword]
51+
52+
// check if password has been saved in clear text
53+
// and has correct length (if the secret has actually been generated)
54+
if !wasGenerated && (len(password) == 0 || len(password) != desiredLength(in)) {
55+
t.Errorf("generated field has wrong length of %d", len(password))
56+
}
57+
58+
// check if auth field has been generated (with separator)
59+
if len(auth) == 0 || !strings.Contains(string(auth), ":") {
60+
t.Errorf("auth field has wrong or no values %s", string(auth))
61+
}
62+
63+
if _, ok := out.Annotations[AnnotationSecretAutoGeneratedAt]; !ok {
64+
t.Errorf("secret has no %s annotation", AnnotationSecretAutoGeneratedAt)
65+
}
66+
}
67+
68+
func TestGenerateBasicAuthWithoutUsername(t *testing.T) {
69+
in := newBasicAuthTestSecret(map[string]string{})
70+
require.NoError(t, mgr.GetClient().Create(context.TODO(), in))
71+
72+
doReconcile(t, in, false)
73+
74+
out := &corev1.Secret{}
75+
require.NoError(t, mgr.GetClient().Get(context.TODO(), client.ObjectKey{
76+
Name: in.Name,
77+
Namespace: in.Namespace}, out))
78+
79+
verifyBasicAuthSecret(t, in, out)
80+
require.Equal(t, "admin", string(out.Data[SecretFieldBasicAuthUsername]))
81+
}
82+
83+
func TestGenerateBasicAuthWithUsername(t *testing.T) {
84+
in := newBasicAuthTestSecret(map[string]string{
85+
AnnotationBasicAuthUsername: "test123",
86+
})
87+
require.NoError(t, mgr.GetClient().Create(context.TODO(), in))
88+
89+
doReconcile(t, in, false)
90+
91+
out := &corev1.Secret{}
92+
require.NoError(t, mgr.GetClient().Get(context.TODO(), client.ObjectKey{
93+
Name: in.Name,
94+
Namespace: in.Namespace}, out))
95+
96+
verifyBasicAuthSecret(t, in, out)
97+
require.Equal(t, "test123", string(out.Data[SecretFieldBasicAuthUsername]))
98+
}
99+
100+
func TestGenerateBasicAuthRegenerate(t *testing.T) {
101+
in := newBasicAuthTestSecret(map[string]string{
102+
AnnotationBasicAuthUsername: "test123",
103+
})
104+
require.NoError(t, mgr.GetClient().Create(context.TODO(), in))
105+
106+
doReconcile(t, in, false)
107+
108+
out := &corev1.Secret{}
109+
require.NoError(t, mgr.GetClient().Get(context.TODO(), client.ObjectKey{
110+
Name: in.Name,
111+
Namespace: in.Namespace}, out))
112+
113+
verifyBasicAuthSecret(t, in, out)
114+
require.Equal(t, "test123", string(out.Data[SecretFieldBasicAuthUsername]))
115+
oldPassword := string(out.Data[SecretFieldBasicAuthPassword])
116+
oldAuth := string(out.Data[SecretFieldBasicAuthIngress])
117+
118+
// force regenerate
119+
out.Annotations[AnnotationSecretRegenerate] = "yes"
120+
require.NoError(t, mgr.GetClient().Update(context.TODO(), out))
121+
122+
doReconcile(t, out, false)
123+
124+
outNew := &corev1.Secret{}
125+
require.NoError(t, mgr.GetClient().Get(context.TODO(), client.ObjectKey{
126+
Name: in.Name,
127+
Namespace: in.Namespace}, outNew))
128+
newPassword := string(outNew.Data[SecretFieldBasicAuthPassword])
129+
newAuth := string(outNew.Data[SecretFieldBasicAuthIngress])
130+
131+
if oldPassword == newPassword {
132+
t.Errorf("secret has not been updated")
133+
}
134+
135+
if oldAuth == newAuth {
136+
t.Errorf("secret has not been updated")
137+
}
138+
}
139+
140+
func TestGenerateBasicAuthNoRegenerate(t *testing.T) {
141+
in := newBasicAuthTestSecret(map[string]string{
142+
AnnotationBasicAuthUsername: "test123",
143+
})
144+
require.NoError(t, mgr.GetClient().Create(context.TODO(), in))
145+
146+
doReconcile(t, in, false)
147+
148+
out := &corev1.Secret{}
149+
require.NoError(t, mgr.GetClient().Get(context.TODO(), client.ObjectKey{
150+
Name: in.Name,
151+
Namespace: in.Namespace}, out))
152+
153+
verifyBasicAuthSecret(t, in, out)
154+
require.Equal(t, "test123", string(out.Data[SecretFieldBasicAuthUsername]))
155+
oldPassword := string(out.Data[SecretFieldBasicAuthPassword])
156+
oldAuth := string(out.Data[SecretFieldBasicAuthIngress])
157+
158+
doReconcile(t, in, false)
159+
160+
outNew := &corev1.Secret{}
161+
require.NoError(t, mgr.GetClient().Get(context.TODO(), client.ObjectKey{
162+
Name: in.Name,
163+
Namespace: in.Namespace}, outNew))
164+
newPassword := string(out.Data[SecretFieldBasicAuthPassword])
165+
newAuth := string(out.Data[SecretFieldBasicAuthIngress])
166+
167+
if oldPassword != newPassword {
168+
t.Errorf("secret has been updated")
169+
}
170+
171+
if oldAuth != newAuth {
172+
t.Errorf("secret has been updated")
173+
}
174+
}

pkg/controller/secret/secret_controller.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ func (r *ReconcileSecret) Reconcile(request reconcile.Request) (reconcile.Result
125125
generator = StringGenerator{
126126
log: reqLogger.WithValues("type", SecretTypeString),
127127
}
128+
case SecretTypeBasicAuth:
129+
generator = BasicAuthGenerator{
130+
log: reqLogger.WithValues("type", SecretTypeBasicAuth),
131+
}
128132
}
129133

130134
res, err := generator.generateData(desired)

pkg/controller/secret/secret_string.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ type StringGenerator struct {
1616
}
1717

1818
func (pg StringGenerator) generateData(instance *corev1.Secret) (reconcile.Result, error) {
19-
toGenerate := instance.Annotations[AnnotationSecretAutoGenerate] // won't generate anything if annotation is not set
19+
toGenerate := instance.Annotations[AnnotationSecretAutoGenerate]
2020

2121
genKeys := strings.Split(toGenerate, ",")
2222

@@ -55,7 +55,7 @@ func (pg StringGenerator) generateData(instance *corev1.Secret) (reconcile.Resul
5555

5656
value, err := generateRandomString(length)
5757
if err != nil {
58-
pg.log.Error(err, "could not generate new instance")
58+
pg.log.Error(err, "could not generate new random string")
5959
return reconcile.Result{RequeueAfter: time.Second * 30}, err
6060
}
6161

pkg/controller/secret/types.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,22 @@ const (
1313
AnnotationSecretSecure = "secret-generator.v1.mittwald.de/secure"
1414
AnnotationSecretType = "secret-generator.v1.mittwald.de/type"
1515
AnnotationSecretLength = "secret-generator.v1.mittwald.de/length"
16+
AnnotationBasicAuthUsername = "secret-generator.v1.mittwald.de/basic-auth-username"
1617
)
1718

1819
type SecretType string
1920

2021
const (
2122
SecretTypeString SecretType = "string"
2223
SecretTypeSSHKeypair SecretType = "ssh-keypair"
24+
SecretTypeBasicAuth SecretType = "basic-auth"
2325
)
2426

2527
func (st SecretType) Validate() error {
2628
switch st {
2729
case SecretTypeString,
28-
SecretTypeSSHKeypair:
30+
SecretTypeSSHKeypair,
31+
SecretTypeBasicAuth:
2932
return nil
3033
}
3134
return fmt.Errorf("%s is not a valid secret type", st)

0 commit comments

Comments
 (0)