Skip to content

Commit db81704

Browse files
authored
Merge pull request #113 from Icinga/tls-support-inline-pem
config.TLS: Inline PEM-encoded Certs, Keys and CAs
2 parents f860775 + 837644f commit db81704

File tree

3 files changed

+242
-17
lines changed

3 files changed

+242
-17
lines changed

config/tls.go

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package config
33
import (
44
"crypto/tls"
55
"crypto/x509"
6+
"encoding/pem"
67
"github.com/pkg/errors"
78
"os"
89
)
@@ -34,13 +35,15 @@ type TLS struct {
3435
// Enable indicates whether TLS is enabled.
3536
Enable bool `yaml:"tls" env:"TLS"`
3637

37-
// Cert is the path to the TLS certificate file. If provided, Key must also be specified.
38+
// Cert is either the path to the TLS certificate file or a raw PEM-encoded string representing it.
39+
// If provided, Key must also be specified.
3840
Cert string `yaml:"cert" env:"CERT"`
3941

40-
// Key is the path to the TLS key file. If specified, Cert must also be provided.
42+
// Key is either the path to the TLS key file or a raw PEM-encoded string representing it.
43+
// If specified, Cert must also be provided.
4144
Key string `yaml:"key" env:"KEY"`
4245

43-
// Ca is the path to the CA certificate file.
46+
// Ca is either the path to the CA certificate file or a raw PEM-encoded string representing it.
4447
Ca string `yaml:"ca" env:"CA"`
4548

4649
// Insecure indicates whether to skip verification of the server's certificate chain and host name.
@@ -49,6 +52,20 @@ type TLS struct {
4952
Insecure bool `yaml:"insecure" env:"INSECURE"`
5053
}
5154

55+
// loadPemOrFile either returns a PEM from within the string or treats it as a file, returning its content.
56+
func loadPemOrFile(pemOrFile string) ([]byte, error) {
57+
block, _ := pem.Decode([]byte(pemOrFile))
58+
if block != nil {
59+
return []byte(pemOrFile), nil
60+
}
61+
62+
data, err := os.ReadFile(pemOrFile)
63+
if err != nil {
64+
return nil, err
65+
}
66+
return data, nil
67+
}
68+
5269
// MakeConfig assembles a [*tls.Config] from the TLS struct and the provided serverName.
5370
// It returns a configured *tls.Config or an error if there are issues with the provided TLS settings.
5471
// If TLS is not enabled (t.Enable is false), it returns nil without an error.
@@ -58,31 +75,44 @@ func (t *TLS) MakeConfig(serverName string) (*tls.Config, error) {
5875
}
5976

6077
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12}
61-
if t.Cert == "" {
62-
if t.Key != "" {
63-
return nil, errors.New("private key given, but client certificate missing")
64-
}
65-
} else if t.Key == "" {
78+
79+
hasKeyWithoutCert := t.Key != "" && t.Cert == ""
80+
hasCertWithoutKey := t.Cert != "" && t.Key == ""
81+
hasClientCert := t.Cert != "" && t.Key != ""
82+
83+
if hasKeyWithoutCert {
84+
return nil, errors.New("private key given, but client certificate missing")
85+
}
86+
if hasCertWithoutKey {
6687
return nil, errors.New("client certificate given, but private key missing")
67-
} else {
68-
crt, err := tls.LoadX509KeyPair(t.Cert, t.Key)
88+
}
89+
if hasClientCert {
90+
certPem, err := loadPemOrFile(t.Cert)
6991
if err != nil {
70-
return nil, errors.Wrap(err, "can't load X.509 key pair")
92+
return nil, errors.Wrap(err, "can't load X.509 client certificate")
93+
}
94+
keyPem, err := loadPemOrFile(t.Key)
95+
if err != nil {
96+
return nil, errors.Wrap(err, "can't load X.509 private key")
7197
}
7298

99+
crt, err := tls.X509KeyPair(certPem, keyPem)
100+
if err != nil {
101+
return nil, errors.Wrap(err, "can't parse client certificate and private key into an X.509 key pair")
102+
}
73103
tlsConfig.Certificates = []tls.Certificate{crt}
74104
}
75105

76106
if t.Insecure {
77107
tlsConfig.InsecureSkipVerify = true
78108
} else if t.Ca != "" {
79-
raw, err := os.ReadFile(t.Ca)
109+
caPem, err := loadPemOrFile(t.Ca)
80110
if err != nil {
81-
return nil, errors.Wrap(err, "can't read CA file")
111+
return nil, errors.Wrap(err, "can't load X.509 CA certificate")
82112
}
83113

84114
tlsConfig.RootCAs = x509.NewCertPool()
85-
if !tlsConfig.RootCAs.AppendCertsFromPEM(raw) {
115+
if !tlsConfig.RootCAs.AppendCertsFromPEM(caPem) {
86116
return nil, errors.New("can't parse CA file")
87117
}
88118
}

config/tls_test.go

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,37 @@ import (
1616
"time"
1717
)
1818

19+
func Test_loadPemOrFile(t *testing.T) {
20+
cert, _, err := generateCert("cert", generateCertOptions{})
21+
require.NoError(t, err)
22+
certPem := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
23+
24+
certFile, err := os.CreateTemp("", "cert-*.pem")
25+
require.NoError(t, err)
26+
defer func(name string) {
27+
_ = os.Remove(name)
28+
}(certFile.Name())
29+
_, err = certFile.Write(certPem)
30+
require.NoError(t, err)
31+
32+
t.Run("Load raw PEM", func(t *testing.T) {
33+
out, err := loadPemOrFile(string(certPem))
34+
require.NoError(t, err)
35+
require.Equal(t, certPem, out)
36+
})
37+
38+
t.Run("Load file", func(t *testing.T) {
39+
out, err := loadPemOrFile(certFile.Name())
40+
require.NoError(t, err)
41+
require.Equal(t, certPem, out)
42+
})
43+
44+
t.Run("Invalid file", func(t *testing.T) {
45+
_, err := loadPemOrFile("/dev/null/nonexistent")
46+
require.Error(t, err)
47+
})
48+
}
49+
1950
func TestTLS_MakeConfig(t *testing.T) {
2051
t.Run("TLS disabled", func(t *testing.T) {
2152
tlsConfig := &TLS{Enable: false}
@@ -48,13 +79,13 @@ func TestTLS_MakeConfig(t *testing.T) {
4879
t.Run("Missing client certificate", func(t *testing.T) {
4980
tlsConfig := &TLS{Enable: true, Key: "test.key"}
5081
_, err := tlsConfig.MakeConfig("icinga.com")
51-
require.Error(t, err)
82+
require.ErrorContains(t, err, "client certificate missing")
5283
})
5384

5485
t.Run("Missing private key", func(t *testing.T) {
5586
tlsConfig := &TLS{Enable: true, Cert: "test.crt"}
5687
_, err := tlsConfig.MakeConfig("icinga.com")
57-
require.Error(t, err)
88+
require.ErrorContains(t, err, "private key missing")
5889
})
5990

6091
t.Run("x509", func(t *testing.T) {
@@ -93,7 +124,7 @@ func TestTLS_MakeConfig(t *testing.T) {
93124
defer func(name string) {
94125
_ = os.Remove(name)
95126
}(corruptFile.Name())
96-
err = os.WriteFile(corruptFile.Name(), []byte("corrupt PEM"), 0600)
127+
err = os.WriteFile(corruptFile.Name(), []byte("-----BEGIN CORRUPT-----\nOOPS\n-----END CORRUPT-----"), 0600)
97128
require.NoError(t, err)
98129

99130
t.Run("Valid certificate and key", func(t *testing.T) {
@@ -104,6 +135,30 @@ func TestTLS_MakeConfig(t *testing.T) {
104135
require.Len(t, config.Certificates, 1)
105136
})
106137

138+
t.Run("Valid certificate and key as PEM", func(t *testing.T) {
139+
certRaw, err := os.ReadFile(certFile.Name())
140+
require.NoError(t, err)
141+
keyRaw, err := os.ReadFile(keyFile.Name())
142+
require.NoError(t, err)
143+
144+
tlsConfig := &TLS{Enable: true, Cert: string(certRaw), Key: string(keyRaw)}
145+
config, err := tlsConfig.MakeConfig("icinga.com")
146+
require.NoError(t, err)
147+
require.NotNil(t, config)
148+
require.Len(t, config.Certificates, 1)
149+
})
150+
151+
t.Run("Valid certificate and key, mixed file and PEM", func(t *testing.T) {
152+
keyRaw, err := os.ReadFile(keyFile.Name())
153+
require.NoError(t, err)
154+
155+
tlsConfig := &TLS{Enable: true, Cert: certFile.Name(), Key: string(keyRaw)}
156+
config, err := tlsConfig.MakeConfig("icinga.com")
157+
require.NoError(t, err)
158+
require.NotNil(t, config)
159+
require.Len(t, config.Certificates, 1)
160+
})
161+
107162
t.Run("Mismatched certificate and key", func(t *testing.T) {
108163
_key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
109164
require.NoError(t, err)
@@ -149,6 +204,17 @@ func TestTLS_MakeConfig(t *testing.T) {
149204
require.Error(t, err)
150205
})
151206

207+
t.Run("Corrupt certificate as PEM", func(t *testing.T) {
208+
corruptRaw, err := os.ReadFile(corruptFile.Name())
209+
require.NoError(t, err)
210+
keyRaw, err := os.ReadFile(keyFile.Name())
211+
require.NoError(t, err)
212+
213+
tlsConfig := &TLS{Enable: true, Cert: string(corruptRaw), Key: string(keyRaw)}
214+
_, err = tlsConfig.MakeConfig("icinga.com")
215+
require.Error(t, err)
216+
})
217+
152218
t.Run("Invalid key path", func(t *testing.T) {
153219
tlsConfig := &TLS{Enable: true, Cert: certFile.Name(), Key: "nonexistent.key"}
154220
_, err := tlsConfig.MakeConfig("icinga.com")
@@ -184,6 +250,17 @@ func TestTLS_MakeConfig(t *testing.T) {
184250
require.NotNil(t, config.RootCAs)
185251
})
186252

253+
t.Run("Valid CA as PEM", func(t *testing.T) {
254+
caRaw, err := os.ReadFile(caFile.Name())
255+
require.NoError(t, err)
256+
257+
tlsConfig := &TLS{Enable: true, Ca: string(caRaw)}
258+
config, err := tlsConfig.MakeConfig("icinga.com")
259+
require.NoError(t, err)
260+
require.NotNil(t, config)
261+
require.NotNil(t, config.RootCAs)
262+
})
263+
187264
t.Run("Invalid CA path", func(t *testing.T) {
188265
tlsConfig := &TLS{Enable: true, Ca: "nonexistent.ca"}
189266
_, err := tlsConfig.MakeConfig("icinga.com")

database/config_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,124 @@ ca: ca.pem`,
161161
},
162162
},
163163
},
164+
{
165+
Name: "TLS with raw PEM",
166+
Data: testutils.ConfigTestData{
167+
Yaml: minimalYaml + `
168+
tls: true
169+
cert: |-
170+
-----BEGIN CERTIFICATE-----
171+
MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw
172+
DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow
173+
EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d
174+
7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B
175+
5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr
176+
BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1
177+
NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l
178+
Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc
179+
6MF9+Yw1Yy0t
180+
-----END CERTIFICATE-----
181+
key: |-
182+
-----BEGIN EC PRIVATE KEY-----
183+
MHcCAQEEIIrYSSNQFaA2Hwf1duRSxKtLYX5CB04fSeQ6tF1aY/PuoAoGCCqGSM49
184+
AwEHoUQDQgAEPR3tU2Fta9ktY+6P9G0cWO+0kETA6SFs38GecTyudlHz6xvCdz8q
185+
EKTcWGekdmdDPsHloRNtsiCa697B2O9IFA==
186+
-----END EC PRIVATE KEY-----
187+
ca: |-
188+
-----BEGIN CERTIFICATE-----
189+
MIICSTCCAfOgAwIBAgIUcmQfIJAvbxdVm0PFanS4FWH71Z0wDQYJKoZIhvcNAQEL
190+
BQAweTELMAkGA1UEBhMCREUxEjAQBgNVBAgMCUZyYW5jb25pYTESMBAGA1UEBwwJ
191+
TnVyZW1iZXJnMUIwQAYDVQQKDDlIb25lc3QgTWFya3VzJyBVc2VkIE51Y2xlYXIg
192+
UG93ZXIgUGxhbnRzIGFuZCBDZXJ0aWZpY2F0ZXMwHhcNMjUwMzA1MDk0ODIwWhcN
193+
MjUwMzA2MDk0ODIwWjB5MQswCQYDVQQGEwJERTESMBAGA1UECAwJRnJhbmNvbmlh
194+
MRIwEAYDVQQHDAlOdXJlbWJlcmcxQjBABgNVBAoMOUhvbmVzdCBNYXJrdXMnIFVz
195+
ZWQgTnVjbGVhciBQb3dlciBQbGFudHMgYW5kIENlcnRpZmljYXRlczBcMA0GCSqG
196+
SIb3DQEBAQUAA0sAMEgCQQCeEGX2IolvELSUjC1DqvJRbTs4DKwE8ZZHDAGrc5K9
197+
DFrLKvkwgfv3g9R2NJE5o/A5vBLq22IDCFdI26M6t10HAgMBAAGjUzBRMB0GA1Ud
198+
DgQWBBQn+dCzVtAzYOGC8tIi9JLmRbWI7jAfBgNVHSMEGDAWgBQn+dCzVtAzYOGC
199+
8tIi9JLmRbWI7jAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA0EAlA27
200+
ti1NKC+o+iZtyU8I/32aPaFme1+eQNIxvqXfw49jSM/FyDjhfZ0XlAxmK6tzF3mM
201+
LJZsYbxapLeyWoA05Q==
202+
-----END CERTIFICATE-----
203+
`,
204+
Env: withMinimalEnv(map[string]string{
205+
"TLS": "1",
206+
"CERT": `-----BEGIN CERTIFICATE-----
207+
MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw
208+
DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow
209+
EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d
210+
7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B
211+
5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr
212+
BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1
213+
NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l
214+
Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc
215+
6MF9+Yw1Yy0t
216+
-----END CERTIFICATE-----`,
217+
"KEY": `-----BEGIN EC PRIVATE KEY-----
218+
MHcCAQEEIIrYSSNQFaA2Hwf1duRSxKtLYX5CB04fSeQ6tF1aY/PuoAoGCCqGSM49
219+
AwEHoUQDQgAEPR3tU2Fta9ktY+6P9G0cWO+0kETA6SFs38GecTyudlHz6xvCdz8q
220+
EKTcWGekdmdDPsHloRNtsiCa697B2O9IFA==
221+
-----END EC PRIVATE KEY-----`,
222+
"CA": `-----BEGIN CERTIFICATE-----
223+
MIICSTCCAfOgAwIBAgIUcmQfIJAvbxdVm0PFanS4FWH71Z0wDQYJKoZIhvcNAQEL
224+
BQAweTELMAkGA1UEBhMCREUxEjAQBgNVBAgMCUZyYW5jb25pYTESMBAGA1UEBwwJ
225+
TnVyZW1iZXJnMUIwQAYDVQQKDDlIb25lc3QgTWFya3VzJyBVc2VkIE51Y2xlYXIg
226+
UG93ZXIgUGxhbnRzIGFuZCBDZXJ0aWZpY2F0ZXMwHhcNMjUwMzA1MDk0ODIwWhcN
227+
MjUwMzA2MDk0ODIwWjB5MQswCQYDVQQGEwJERTESMBAGA1UECAwJRnJhbmNvbmlh
228+
MRIwEAYDVQQHDAlOdXJlbWJlcmcxQjBABgNVBAoMOUhvbmVzdCBNYXJrdXMnIFVz
229+
ZWQgTnVjbGVhciBQb3dlciBQbGFudHMgYW5kIENlcnRpZmljYXRlczBcMA0GCSqG
230+
SIb3DQEBAQUAA0sAMEgCQQCeEGX2IolvELSUjC1DqvJRbTs4DKwE8ZZHDAGrc5K9
231+
DFrLKvkwgfv3g9R2NJE5o/A5vBLq22IDCFdI26M6t10HAgMBAAGjUzBRMB0GA1Ud
232+
DgQWBBQn+dCzVtAzYOGC8tIi9JLmRbWI7jAfBgNVHSMEGDAWgBQn+dCzVtAzYOGC
233+
8tIi9JLmRbWI7jAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA0EAlA27
234+
ti1NKC+o+iZtyU8I/32aPaFme1+eQNIxvqXfw49jSM/FyDjhfZ0XlAxmK6tzF3mM
235+
LJZsYbxapLeyWoA05Q==
236+
-----END CERTIFICATE-----`,
237+
}),
238+
},
239+
Expected: Config{
240+
Type: "pgsql",
241+
Host: "localhost",
242+
User: "icinga",
243+
Database: "icingadb",
244+
Password: "secret",
245+
Options: defaultOptions,
246+
TlsOptions: config.TLS{
247+
Enable: true,
248+
Cert: `-----BEGIN CERTIFICATE-----
249+
MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw
250+
DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow
251+
EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d
252+
7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B
253+
5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr
254+
BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1
255+
NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l
256+
Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc
257+
6MF9+Yw1Yy0t
258+
-----END CERTIFICATE-----`,
259+
Key: `-----BEGIN EC PRIVATE KEY-----
260+
MHcCAQEEIIrYSSNQFaA2Hwf1duRSxKtLYX5CB04fSeQ6tF1aY/PuoAoGCCqGSM49
261+
AwEHoUQDQgAEPR3tU2Fta9ktY+6P9G0cWO+0kETA6SFs38GecTyudlHz6xvCdz8q
262+
EKTcWGekdmdDPsHloRNtsiCa697B2O9IFA==
263+
-----END EC PRIVATE KEY-----`,
264+
Ca: `-----BEGIN CERTIFICATE-----
265+
MIICSTCCAfOgAwIBAgIUcmQfIJAvbxdVm0PFanS4FWH71Z0wDQYJKoZIhvcNAQEL
266+
BQAweTELMAkGA1UEBhMCREUxEjAQBgNVBAgMCUZyYW5jb25pYTESMBAGA1UEBwwJ
267+
TnVyZW1iZXJnMUIwQAYDVQQKDDlIb25lc3QgTWFya3VzJyBVc2VkIE51Y2xlYXIg
268+
UG93ZXIgUGxhbnRzIGFuZCBDZXJ0aWZpY2F0ZXMwHhcNMjUwMzA1MDk0ODIwWhcN
269+
MjUwMzA2MDk0ODIwWjB5MQswCQYDVQQGEwJERTESMBAGA1UECAwJRnJhbmNvbmlh
270+
MRIwEAYDVQQHDAlOdXJlbWJlcmcxQjBABgNVBAoMOUhvbmVzdCBNYXJrdXMnIFVz
271+
ZWQgTnVjbGVhciBQb3dlciBQbGFudHMgYW5kIENlcnRpZmljYXRlczBcMA0GCSqG
272+
SIb3DQEBAQUAA0sAMEgCQQCeEGX2IolvELSUjC1DqvJRbTs4DKwE8ZZHDAGrc5K9
273+
DFrLKvkwgfv3g9R2NJE5o/A5vBLq22IDCFdI26M6t10HAgMBAAGjUzBRMB0GA1Ud
274+
DgQWBBQn+dCzVtAzYOGC8tIi9JLmRbWI7jAfBgNVHSMEGDAWgBQn+dCzVtAzYOGC
275+
8tIi9JLmRbWI7jAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA0EAlA27
276+
ti1NKC+o+iZtyU8I/32aPaFme1+eQNIxvqXfw49jSM/FyDjhfZ0XlAxmK6tzF3mM
277+
LJZsYbxapLeyWoA05Q==
278+
-----END CERTIFICATE-----`,
279+
},
280+
},
281+
},
164282
{
165283
Name: "max_connections cannot be 0",
166284
Data: testutils.ConfigTestData{

0 commit comments

Comments
 (0)