Skip to content

Commit 0803b90

Browse files
committed
add support for custom TLS certificates
1 parent cde61f3 commit 0803b90

File tree

11 files changed

+290
-19
lines changed

11 files changed

+290
-19
lines changed

docs/reference/cluster_manifest.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,3 +359,24 @@ CPU and memory limits for the sidecar container.
359359
* **memory**
360360
memory limits for the sidecar container. Optional, overrides the
361361
`default_memory_limits` operator configuration parameter. Optional.
362+
363+
## Custom TLS certificates
364+
365+
Those parameters are grouped under the `tls` top-level key.
366+
367+
* **secretName**
368+
By setting the `secretName` value, the cluster will switch to load the given
369+
Kubernetes Secret into the container as a volume and uses that as the
370+
certificate instead. It is up to the user to create and manage the
371+
Kubernetes Secret either by hand or using a tool like the CertManager
372+
operator.
373+
374+
* **certificateFile**
375+
Filename of the certificate. Defaults to "tls.crt".
376+
377+
* **privateKeyFile**
378+
Filename of the private key. Defaults to "tls.key".
379+
380+
* **caFile**
381+
Optional filename to the CA certificate. Useful when the client connects
382+
with `sslmode=verify-ca` or `sslmode=verify-full`.

docs/user.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,3 +511,55 @@ monitoring is outside the scope of operator responsibilities. See
511511
[configuration reference](reference/cluster_manifest.md) and
512512
[administrator documentation](administrator.md) for details on how backups are
513513
executed.
514+
515+
## Custom TLS certificates
516+
517+
By default, the spilo image generates its own TLS certificate during startup.
518+
This certificate is not secure since it cannot be verified and thus doesn't
519+
protect from active MITM attacks. In this section we show how a Kubernete
520+
Secret resources can be loaded with a custom TLS certificate.
521+
522+
Before applying these changes, the operator must also be configured with the
523+
`spilo_fsgroup` set to the GID matching the postgres user group. If the value
524+
is not provided, the cluster will default to `103` which is the GID from the
525+
default spilo image.
526+
527+
Upload the cert as a kubernetes secret:
528+
```sh
529+
kubectl create secret tls pg-tls \
530+
--key pg-tls.key \
531+
--cert pg-tls.crt
532+
```
533+
534+
Or with a CA:
535+
```sh
536+
kubectl create secret generic pg-tls \
537+
--from-file=tls.crt=server.crt \
538+
--from-file=tls.key=server.key \
539+
--from-file=ca.crt=ca.crt
540+
```
541+
542+
Alternatively it is also possible to use
543+
[cert-manager](https://cert-manager.io/docs/) to generate these secrets.
544+
545+
Then configure the postgres resource with the TLS secret:
546+
547+
```yaml
548+
apiVersion: "acid.zalan.do/v1"
549+
kind: postgresql
550+
551+
metadata:
552+
name: acid-test-cluster
553+
spec:
554+
tls:
555+
secretName: "pg-tls"
556+
caFile: "ca.crt" # add this if the secret is configured with a CA
557+
```
558+
559+
Certificate rotation is handled in the spilo image which checks every 5
560+
minutes if the certificates have changed and reloads postgres accordingly.
561+
562+
Known issues:
563+
564+
* Existing clusters don't handle changes to the `spilo_fsgroup` well.
565+

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ require (
1111
github.com/lib/pq v1.2.0
1212
github.com/motomux/pretty v0.0.0-20161209205251-b2aad2c9a95d
1313
github.com/sirupsen/logrus v1.4.2
14+
github.com/stretchr/testify v1.4.0
1415
golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413 // indirect
1516
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 // indirect
1617
golang.org/x/sys v0.0.0-20191210023423-ac6580df4449 // indirect

go.sum

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
275275
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
276276
github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
277277
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
278+
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
278279
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
279280
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
280281
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=

manifests/complete-postgres-manifest.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,10 @@ spec:
100100
# env:
101101
# - name: "USEFUL_VAR"
102102
# value: "perhaps-true"
103+
104+
# Custom TLS certificate. Disabled unless tls.secretName has a value.
105+
tls:
106+
secretName: "" # should correspond to a Kubernetes Secret resource to load
107+
certificateFile: "tls.crt"
108+
privateKeyFile: "tls.key"
109+
caFile: "" # optionally configure Postgres with a CA certificate

manifests/postgresql.crd.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,19 @@ spec:
251251
type: string
252252
teamId:
253253
type: string
254+
tls:
255+
type: object
256+
required:
257+
- secretName
258+
properties:
259+
secretName:
260+
type: string
261+
certificateFile:
262+
type: string
263+
privateKeyFile:
264+
type: string
265+
caFile:
266+
type: string
254267
tolerations:
255268
type: array
256269
items:

pkg/apis/acid.zalan.do/v1/crds.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,24 @@ var PostgresCRDResourceValidation = apiextv1beta1.CustomResourceValidation{
417417
"teamId": {
418418
Type: "string",
419419
},
420+
"tls": {
421+
Type: "object",
422+
Required: []string{"secretName"},
423+
Properties: map[string]apiextv1beta1.JSONSchemaProps{
424+
"secretName": {
425+
Type: "string",
426+
},
427+
"certificateFile": {
428+
Type: "string",
429+
},
430+
"privateKeyFile": {
431+
Type: "string",
432+
},
433+
"caFile": {
434+
Type: "string",
435+
},
436+
},
437+
},
420438
"tolerations": {
421439
Type: "array",
422440
Items: &apiextv1beta1.JSONSchemaPropsOrArray{

pkg/apis/acid.zalan.do/v1/postgresql_type.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ type PostgresSpec struct {
6161
StandbyCluster *StandbyDescription `json:"standby"`
6262
PodAnnotations map[string]string `json:"podAnnotations"`
6363
ServiceAnnotations map[string]string `json:"serviceAnnotations"`
64+
TLS *TLSDescription `json:"tls"`
6465

6566
// deprecated json tags
6667
InitContainersOld []v1.Container `json:"init_containers,omitempty"`
@@ -126,6 +127,13 @@ type StandbyDescription struct {
126127
S3WalPath string `json:"s3_wal_path,omitempty"`
127128
}
128129

130+
type TLSDescription struct {
131+
SecretName string `json:"secretName,omitempty"`
132+
CertificateFile string `json:"certificateFile,omitempty"`
133+
PrivateKeyFile string `json:"privateKeyFile,omitempty"`
134+
CAFile string `json:"caFile,omitempty"`
135+
}
136+
129137
// CloneDescription describes which cluster the new should clone and up to which point in time
130138
type CloneDescription struct {
131139
ClusterName string `json:"cluster,omitempty"`

pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/cluster/k8sres.go

Lines changed: 83 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cluster
33
import (
44
"encoding/json"
55
"fmt"
6+
"path"
67
"sort"
78

89
"github.com/sirupsen/logrus"
@@ -30,7 +31,10 @@ const (
3031
patroniPGBinariesParameterName = "bin_dir"
3132
patroniPGParametersParameterName = "parameters"
3233
patroniPGHBAConfParameterName = "pg_hba"
33-
localHost = "127.0.0.1/32"
34+
35+
// the gid of the postgres user in the default spilo image
36+
spiloPostgresGID = 103
37+
localHost = "127.0.0.1/32"
3438
)
3539

3640
type pgUser struct {
@@ -446,6 +450,7 @@ func generatePodTemplate(
446450
podAntiAffinityTopologyKey string,
447451
additionalSecretMount string,
448452
additionalSecretMountPath string,
453+
volumes []v1.Volume,
449454
) (*v1.PodTemplateSpec, error) {
450455

451456
terminateGracePeriodSeconds := terminateGracePeriod
@@ -464,6 +469,7 @@ func generatePodTemplate(
464469
InitContainers: initContainers,
465470
Tolerations: *tolerationsSpec,
466471
SecurityContext: &securityContext,
472+
Volumes: volumes,
467473
}
468474

469475
if shmVolume != nil && *shmVolume {
@@ -724,6 +730,7 @@ func (c *Cluster) generateStatefulSet(spec *acidv1.PostgresSpec) (*appsv1.Statef
724730
sidecarContainers []v1.Container
725731
podTemplate *v1.PodTemplateSpec
726732
volumeClaimTemplate *v1.PersistentVolumeClaim
733+
volumes []v1.Volume
727734
)
728735

729736
// Improve me. Please.
@@ -840,21 +847,76 @@ func (c *Cluster) generateStatefulSet(spec *acidv1.PostgresSpec) (*appsv1.Statef
840847
}
841848

842849
// generate environment variables for the spilo container
843-
spiloEnvVars := deduplicateEnvVars(
844-
c.generateSpiloPodEnvVars(c.Postgresql.GetUID(), spiloConfiguration, &spec.Clone,
845-
spec.StandbyCluster, customPodEnvVarsList), c.containerName(), c.logger)
850+
spiloEnvVars := c.generateSpiloPodEnvVars(
851+
c.Postgresql.GetUID(),
852+
spiloConfiguration,
853+
&spec.Clone,
854+
spec.StandbyCluster,
855+
customPodEnvVarsList,
856+
)
846857

847858
// pickup the docker image for the spilo container
848859
effectiveDockerImage := util.Coalesce(spec.DockerImage, c.OpConfig.DockerImage)
849860

861+
// determine the FSGroup for the spilo pod
862+
effectiveFSGroup := c.OpConfig.Resources.SpiloFSGroup
863+
if spec.SpiloFSGroup != nil {
864+
effectiveFSGroup = spec.SpiloFSGroup
865+
}
866+
850867
volumeMounts := generateVolumeMounts(spec.Volume)
851868

869+
// configure TLS with a custom secret volume
870+
if spec.TLS != nil && spec.TLS.SecretName != "" {
871+
if effectiveFSGroup == nil {
872+
c.logger.Warnf("Setting the default FSGroup to satisfy the TLS configuration")
873+
fsGroup := int64(spiloPostgresGID)
874+
effectiveFSGroup = &fsGroup
875+
}
876+
// this is combined with the FSGroup above to give read access to the
877+
// postgres user
878+
defaultMode := int32(0640)
879+
volumes = append(volumes, v1.Volume{
880+
Name: "tls-secret",
881+
VolumeSource: v1.VolumeSource{
882+
Secret: &v1.SecretVolumeSource{
883+
SecretName: spec.TLS.SecretName,
884+
DefaultMode: &defaultMode,
885+
},
886+
},
887+
})
888+
889+
mountPath := "/tls"
890+
volumeMounts = append(volumeMounts, v1.VolumeMount{
891+
MountPath: mountPath,
892+
Name: "tls-secret",
893+
ReadOnly: true,
894+
})
895+
896+
// use the same filenames as Secret resources by default
897+
certFile := ensurePath(spec.TLS.CertificateFile, mountPath, "tls.crt")
898+
privateKeyFile := ensurePath(spec.TLS.PrivateKeyFile, mountPath, "tls.key")
899+
spiloEnvVars = append(
900+
spiloEnvVars,
901+
v1.EnvVar{Name: "SSL_CERTIFICATE_FILE", Value: certFile},
902+
v1.EnvVar{Name: "SSL_PRIVATE_KEY_FILE", Value: privateKeyFile},
903+
)
904+
905+
if spec.TLS.CAFile != "" {
906+
caFile := ensurePath(spec.TLS.CAFile, mountPath, "")
907+
spiloEnvVars = append(
908+
spiloEnvVars,
909+
v1.EnvVar{Name: "SSL_CA_FILE", Value: caFile},
910+
)
911+
}
912+
}
913+
852914
// generate the spilo container
853915
c.logger.Debugf("Generating Spilo container, environment variables: %v", spiloEnvVars)
854916
spiloContainer := generateContainer(c.containerName(),
855917
&effectiveDockerImage,
856918
resourceRequirements,
857-
spiloEnvVars,
919+
deduplicateEnvVars(spiloEnvVars, c.containerName(), c.logger),
858920
volumeMounts,
859921
c.OpConfig.Resources.SpiloPrivileged,
860922
)
@@ -893,16 +955,10 @@ func (c *Cluster) generateStatefulSet(spec *acidv1.PostgresSpec) (*appsv1.Statef
893955
tolerationSpec := tolerations(&spec.Tolerations, c.OpConfig.PodToleration)
894956
effectivePodPriorityClassName := util.Coalesce(spec.PodPriorityClassName, c.OpConfig.PodPriorityClassName)
895957

896-
// determine the FSGroup for the spilo pod
897-
effectiveFSGroup := c.OpConfig.Resources.SpiloFSGroup
898-
if spec.SpiloFSGroup != nil {
899-
effectiveFSGroup = spec.SpiloFSGroup
900-
}
901-
902958
annotations := c.generatePodAnnotations(spec)
903959

904960
// generate pod template for the statefulset, based on the spilo container and sidecars
905-
if podTemplate, err = generatePodTemplate(
961+
podTemplate, err = generatePodTemplate(
906962
c.Namespace,
907963
c.labelsSet(true),
908964
annotations,
@@ -920,10 +976,9 @@ func (c *Cluster) generateStatefulSet(spec *acidv1.PostgresSpec) (*appsv1.Statef
920976
c.OpConfig.EnablePodAntiAffinity,
921977
c.OpConfig.PodAntiAffinityTopologyKey,
922978
c.OpConfig.AdditionalSecretMount,
923-
c.OpConfig.AdditionalSecretMountPath); err != nil {
924-
return nil, fmt.Errorf("could not generate pod template: %v", err)
925-
}
926-
979+
c.OpConfig.AdditionalSecretMountPath,
980+
volumes,
981+
)
927982
if err != nil {
928983
return nil, fmt.Errorf("could not generate pod template: %v", err)
929984
}
@@ -1539,7 +1594,8 @@ func (c *Cluster) generateLogicalBackupJob() (*batchv1beta1.CronJob, error) {
15391594
false,
15401595
"",
15411596
c.OpConfig.AdditionalSecretMount,
1542-
c.OpConfig.AdditionalSecretMountPath); err != nil {
1597+
c.OpConfig.AdditionalSecretMountPath,
1598+
nil); err != nil {
15431599
return nil, fmt.Errorf("could not generate pod template for logical backup pod: %v", err)
15441600
}
15451601

@@ -1671,3 +1727,13 @@ func (c *Cluster) generateLogicalBackupPodEnvVars() []v1.EnvVar {
16711727
func (c *Cluster) getLogicalBackupJobName() (jobName string) {
16721728
return "logical-backup-" + c.clusterName().Name
16731729
}
1730+
1731+
func ensurePath(file string, defaultDir string, defaultFile string) string {
1732+
if file == "" {
1733+
return path.Join(defaultDir, defaultFile)
1734+
}
1735+
if !path.IsAbs(file) {
1736+
return path.Join(defaultDir, file)
1737+
}
1738+
return file
1739+
}

0 commit comments

Comments
 (0)