Skip to content

Commit 9cabd12

Browse files
[v1] Add HTTP TLS (#425)
* feat: add SSL/TLS support for secure connections - Add environment variables for SSL configuration - Implement conditional SSL server startup based on configuration * Add additional SSL/TLS config options - Added a way to configure cipher suites - Added a way to configure TLS min version * Change SSL config variables to use file paths and improve test coverage * Remove t.Pararell() from config test * Add detailed SSL/TLS configuration documentation to README --------- Co-authored-by: chanyongkit <[email protected]>
1 parent da12b7c commit 9cabd12

File tree

7 files changed

+238
-11
lines changed

7 files changed

+238
-11
lines changed

README.md

+22
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,28 @@ The application can be configured using the following environment variables, whi
9797
| `LOG_LEVEL` | [Log level](https://pkg.go.dev/golang.org/x/exp/slog#Level) (defaults to "info" if not set) | `""` (empty string) |
9898
| `HTTP_TIMEOUT`| Timeout for HTTP requests to Logstash API in [Go duration format](https://golang.org/pkg/time/#ParseDuration) | `2s` |
9999

100+
#### SSL/TLS Configuration
101+
102+
The exporter supports serving metrics over HTTPS with the following configuration options:
103+
104+
| Variable Name | Description | Default Value |
105+
|---------------|-----------------------------------------------------------------------------------------------|-------------------------|
106+
| `ENABLE_SSL` | Enable SSL/TLS for the HTTP server (TRUE or FALSE) | `FALSE` |
107+
| `SSL_CERT_FILE_PATH` | File path to the SSL certificate file (required when ENABLE_SSL=TRUE) | `""` |
108+
| `SSL_KEY_FILE_PATH` | File path to the SSL private key file (required when ENABLE_SSL=TRUE) | `""` |
109+
| `SSL_CIPHER_LIST` | Comma-separated list of SSL cipher suites to use | TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,TLS_RSA_WITH_AES_256_GCM_SHA384,TLS_RSA_WITH_AES_256_CBC_SHA |
110+
| `SSL_MIN_VERSION` | Minimum TLS version (1.0, 1.1, 1.2, 1.3) | `1.2` |
111+
112+
When `ENABLE_SSL` is set to `TRUE`, you must provide valid paths to both certificate and key files. Example:
113+
114+
```sh
115+
ENABLE_SSL=TRUE
116+
SSL_CERT_FILE_PATH=/path/to/certificate.crt
117+
SSL_KEY_FILE_PATH=/path/to/private.key
118+
```
119+
120+
For security reasons, TLS 1.2 is configured as the minimum supported version by default. You can modify the TLS version and cipher suites based on your security requirements.
121+
100122
All configuration variables can be checked in the [config directory](./config/).
101123

102124
## Building

cmd/exporter/main.go

+16-2
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ func main() {
3636

3737
port, host := config.Port, config.Host
3838
logstashUrl := config.LogstashUrl
39+
enableSSL := config.EnableSSL == "TRUE"
40+
sslCertFilePath := config.SSLCertFilePath
41+
sslKeyFilePath := config.SSLKeyFilePath
42+
tlsConfig, err := config.SetupTLS()
43+
if err != nil {
44+
log.Fatalf("failed to setup tls: %s",err)
45+
}
3946

4047
slog.Debug("application starting... ")
4148
versionInfo := config.GetVersionInfo()
@@ -56,8 +63,15 @@ func main() {
5663
prometheus.MustRegister(collectorManager)
5764

5865
slog.Info("starting server on", "host", host, "port", port)
59-
if err := appServer.ListenAndServe(); err != nil {
66+
if enableSSL {
67+
appServer.TLSConfig = tlsConfig
68+
err = appServer.ListenAndServeTLS(sslCertFilePath, sslKeyFilePath)
69+
} else {
70+
err = appServer.ListenAndServe()
71+
}
72+
73+
if err != nil {
6074
slog.Error("failed to listen and serve", "err", err)
61-
os.Exit(1)
75+
os.Exit(1)
6276
}
6377
}

config/http_config_test.go

-6
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88

99
func TestGetHttpTimeout(t *testing.T) {
1010
t.Run("DefaultTimeout", func(t *testing.T) {
11-
t.Parallel()
1211
os.Unsetenv(httpTimeoutEnvVar)
1312
timeout, err := GetHttpTimeout()
1413
if err != nil {
@@ -20,7 +19,6 @@ func TestGetHttpTimeout(t *testing.T) {
2019
})
2120

2221
t.Run("CustomTimeout", func(t *testing.T) {
23-
t.Parallel()
2422
expectedTimeout := "5s"
2523
os.Setenv(httpTimeoutEnvVar, expectedTimeout)
2624
defer os.Unsetenv(httpTimeoutEnvVar)
@@ -35,7 +33,6 @@ func TestGetHttpTimeout(t *testing.T) {
3533
})
3634

3735
t.Run("InvalidTimeout", func(t *testing.T) {
38-
t.Parallel()
3936
os.Setenv(httpTimeoutEnvVar, "invalid")
4037
defer os.Unsetenv(httpTimeoutEnvVar)
4138
_, err := GetHttpTimeout()
@@ -47,7 +44,6 @@ func TestGetHttpTimeout(t *testing.T) {
4744

4845
func TestGetHttpInsecure(t *testing.T) {
4946
t.Run("DefaultInsecure", func(t *testing.T) {
50-
t.Parallel()
5147
os.Unsetenv(httpInsecureEnvVar)
5248
insecure := GetHttpInsecure()
5349
if insecure != false {
@@ -56,7 +52,6 @@ func TestGetHttpInsecure(t *testing.T) {
5652
})
5753

5854
t.Run("CustomInsecure", func(t *testing.T) {
59-
t.Parallel()
6055
expectedInsecure := true
6156
os.Setenv(httpInsecureEnvVar, "true")
6257
defer os.Unsetenv(httpInsecureEnvVar)
@@ -67,7 +62,6 @@ func TestGetHttpInsecure(t *testing.T) {
6762
})
6863

6964
t.Run("InvalidInsecure", func(t *testing.T) {
70-
t.Parallel()
7165
expectedInsecure := false
7266
os.Setenv(httpInsecureEnvVar, "invalid")
7367
defer os.Unsetenv(httpInsecureEnvVar)

config/server_config.go

+15
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
package config
22

33
var (
4+
// SSL determines if the exporter should use HTTPS instead of HTTP
5+
// Defaults to "FALSE"
6+
// Can be overridden by setting the SSL environment variable
7+
EnableSSL = getEnvWithDefault("ENABLE_SSL", "FALSE")
8+
9+
// SSL_CERT_FILE_PATH specifies the file path to the SSL certificate file
10+
// Must be set if SSL is "TRUE"
11+
// Can be overridden by setting the SSL_CERT_FILE_PATH environment variable
12+
SSLCertFilePath = getEnvWithDefault("SSL_CERT_FILE_PATH","")
13+
14+
// SSL_KEY_FILE_PATH specifies the file path to the SSL private key file
15+
// Must be set if SSL is "TRUE"
16+
// Can be overridden by setting the SSL_KEY_FILE_PATH environment variable
17+
SSLKeyFilePath = getEnvWithDefault("SSL_KEY_FILE_PATH","")
18+
419
// Port is the port the exporter will listen on.
520
// Defaults to 9198
621
// Can be overridden by setting the PORT environment variable

config/tls_config.go

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package config
2+
3+
import (
4+
"fmt"
5+
"crypto/tls"
6+
"strings"
7+
)
8+
9+
var(
10+
SSLCipherList = getEnvWithDefault("SSL_CIPHER_LIST","TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,TLS_RSA_WITH_AES_256_GCM_SHA384,TLS_RSA_WITH_AES_256_CBC_SHA")
11+
SSLMinVersion = getEnvWithDefault("SSL_MIN_VERSION","1.2")
12+
)
13+
14+
func SetupTLS() (*tls.Config, error) {
15+
var cipherSuites []uint16
16+
if SSLCipherList != "" {
17+
cipherMap := make(map[string]uint16)
18+
for _, suite := range tls.CipherSuites() {
19+
cipherMap[suite.Name] = suite.ID
20+
}
21+
for _, suite := range tls.InsecureCipherSuites() {
22+
cipherMap[suite.Name] = suite.ID
23+
}
24+
25+
for _, cipher := range strings.Split(SSLCipherList, ",") {
26+
cipher = strings.TrimSpace(cipher)
27+
if id, exists := cipherMap[cipher]; exists {
28+
cipherSuites = append(cipherSuites, id)
29+
} else {
30+
return nil, fmt.Errorf("unsupported cipher suite: %s", cipher)
31+
}
32+
}
33+
}
34+
35+
var minVersion uint16
36+
switch SSLMinVersion {
37+
case "1.0":
38+
minVersion = tls.VersionTLS10
39+
case "1.1":
40+
minVersion = tls.VersionTLS11
41+
case "1.2":
42+
minVersion = tls.VersionTLS12
43+
case "1.3", "":
44+
minVersion = tls.VersionTLS13
45+
default:
46+
return nil, fmt.Errorf("invalid TLS version: %s", SSLMinVersion)
47+
}
48+
49+
tlsConfig := &tls.Config{
50+
MinVersion: minVersion,
51+
CipherSuites: cipherSuites,
52+
}
53+
54+
return tlsConfig, nil
55+
}

config/tls_config_test.go

+115
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package config
2+
3+
import (
4+
"crypto/tls"
5+
"testing"
6+
)
7+
8+
func TestSetupTLS(t *testing.T) {
9+
t.Run("default configuration", func(t *testing.T) {
10+
tlsConfig, err := SetupTLS()
11+
12+
if err != nil {
13+
t.Fatalf("Unexpected error setting up TLS: %v", err)
14+
}
15+
16+
if tlsConfig == nil {
17+
t.Fatal("Expected TLS config, got nil")
18+
}
19+
20+
if tlsConfig.MinVersion != tls.VersionTLS12 {
21+
t.Errorf("Expected MinVersion TLS 1.2, got %d", tlsConfig.MinVersion)
22+
}
23+
24+
expectedCipherSuites := []uint16{
25+
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
26+
tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
27+
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
28+
tls.TLS_RSA_WITH_AES_256_CBC_SHA,
29+
}
30+
31+
if len(tlsConfig.CipherSuites) != len(expectedCipherSuites) {
32+
t.Errorf("Expected %d cipher suites, got %d", len(expectedCipherSuites), len(tlsConfig.CipherSuites))
33+
}
34+
})
35+
36+
t.Run("custom TLS version", func(t *testing.T) {
37+
// Save original values
38+
originalMinVersion := SSLMinVersion
39+
defer func() {
40+
SSLMinVersion = originalMinVersion
41+
}()
42+
43+
// Test TLS 1.1
44+
SSLMinVersion = "1.1"
45+
tlsConfig, err := SetupTLS()
46+
if err != nil {
47+
t.Fatalf("Unexpected error setting up TLS with version 1.1: %v", err)
48+
}
49+
if tlsConfig.MinVersion != tls.VersionTLS11 {
50+
t.Errorf("Expected MinVersion TLS 1.1, got %d", tlsConfig.MinVersion)
51+
}
52+
53+
// Test TLS 1.0
54+
SSLMinVersion = "1.0"
55+
tlsConfig, err = SetupTLS()
56+
if err != nil {
57+
t.Fatalf("Unexpected error setting up TLS with version 1.0: %v", err)
58+
}
59+
if tlsConfig.MinVersion != tls.VersionTLS10 {
60+
t.Errorf("Expected MinVersion TLS 1.0, got %d", tlsConfig.MinVersion)
61+
}
62+
63+
// Test TLS 1.3
64+
SSLMinVersion = "1.3"
65+
tlsConfig, err = SetupTLS()
66+
if err != nil {
67+
t.Fatalf("Unexpected error setting up TLS with version 1.3: %v", err)
68+
}
69+
if tlsConfig.MinVersion != tls.VersionTLS13 {
70+
t.Errorf("Expected MinVersion TLS 1.3, got %d", tlsConfig.MinVersion)
71+
}
72+
73+
// Test invalid version
74+
SSLMinVersion = "invalid"
75+
_, err = SetupTLS()
76+
if err == nil {
77+
t.Errorf("Expected error for invalid TLS version, got nil")
78+
}
79+
})
80+
81+
t.Run("custom cipher suite", func(t *testing.T) {
82+
// Save original values
83+
originalCipherList := SSLCipherList
84+
defer func() {
85+
SSLCipherList = originalCipherList
86+
}()
87+
88+
// Test empty cipher list (should default to Go's defaults)
89+
SSLCipherList = ""
90+
tlsConfig, err := SetupTLS()
91+
if err != nil {
92+
t.Fatalf("Unexpected error setting up TLS with empty cipher list: %v", err)
93+
}
94+
if tlsConfig.CipherSuites != nil {
95+
t.Errorf("Expected nil CipherSuites for empty list, got %v", tlsConfig.CipherSuites)
96+
}
97+
98+
// Test single cipher
99+
SSLCipherList = "TLS_RSA_WITH_AES_256_CBC_SHA"
100+
tlsConfig, err = SetupTLS()
101+
if err != nil {
102+
t.Fatalf("Unexpected error setting up TLS with single cipher: %v", err)
103+
}
104+
if len(tlsConfig.CipherSuites) != 1 {
105+
t.Errorf("Expected 1 cipher suite, got %d", len(tlsConfig.CipherSuites))
106+
}
107+
108+
// Test invalid cipher
109+
SSLCipherList = "INVALID_CIPHER"
110+
_, err = SetupTLS()
111+
if err == nil {
112+
t.Errorf("Expected error for invalid cipher suite, got nil")
113+
}
114+
})
115+
}

server/versioninfo_test.go

+15-3
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ func TestHandleVersionInfo(t *testing.T) {
6969
})
7070

7171
t.Run("invalid JSON", func(t *testing.T) {
72-
t.Parallel()
72+
// Don't use t.Parallel() for this test to avoid race conditions
7373
versionInfo := &config.VersionInfo{
7474
Version: "version",
7575
GitCommit: "git commit",
@@ -88,14 +88,26 @@ func TestHandleVersionInfo(t *testing.T) {
8888
t.Errorf("expected 2 writes, but got: %d", len(w.writes))
8989
}
9090

91-
firstWrite := string(w.writes[0])
91+
// Create a copy of the data to avoid race conditions
92+
var firstWrite, secondWrite string
93+
if len(w.writes) > 0 {
94+
firstWriteData := make([]byte, len(w.writes[0]))
95+
copy(firstWriteData, w.writes[0])
96+
firstWrite = string(firstWriteData)
97+
}
98+
99+
if len(w.writes) > 1 {
100+
secondWriteData := make([]byte, len(w.writes[1]))
101+
copy(secondWriteData, w.writes[1])
102+
secondWrite = string(secondWriteData)
103+
}
104+
92105
expectedFirstWrite := `{"Version":"version","SemanticVersion":"","GitCommit":"git commit","GoVersion":"go version","BuildArch":"build arch","BuildOS":"build os","BuildDate":"build date"}`
93106
expectedFirstWrite = expectedFirstWrite + "\n"
94107
if firstWrite != expectedFirstWrite {
95108
t.Errorf("expected first write to be %s, but got: %s", expectedFirstWrite, firstWrite)
96109
}
97110

98-
secondWrite := string(w.writes[1])
99111
expectedSecondWrite := "EOF\n"
100112
if secondWrite != expectedSecondWrite {
101113
t.Errorf("expected second write to be %s, but got: %s", expectedSecondWrite, secondWrite)

0 commit comments

Comments
 (0)