Skip to content

Commit 9176dfe

Browse files
authored
[receiver/tlscheck] Add File-Based Certificate Checks (#38924)
<!--Ex. Fixing a bug - Describe the bug and how this fixes the issue. Ex. Adding a feature - Explain what this achieves.--> #### Description This PR adds certificate checks for certs stored locally on disk. It only covers the case of PEM encoded certificate files. <!-- Issue number (e.g. #1234) or full URL to issue, if applicable. --> #### Link to tracking issue [Fixes 38906](#38906) <!--Describe what testing was performed and which tests were added.--> #### Testing Tests were added for file-based certs <!--Describe the documentation added.--> #### Documentation Documentation has been added to the README
1 parent 38c6bd4 commit 9176dfe

17 files changed

+490
-135
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Use this changelog template to create an entry for release notes.
2+
3+
# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
4+
change_type: breaking
5+
6+
# The name of the component, or a single word describing the area of concern, (e.g. filelogreceiver)
7+
component: tlscheckreceiver
8+
9+
# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
10+
note: Add file-based TLS certificate checks
11+
12+
# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists.
13+
issues: [38906]
14+
15+
# (Optional) One or more lines of additional information to render under the primary note.
16+
# These lines will be padded with 2 spaces and then inserted directly into the document.
17+
# Use pipe (|) for multiline entries.
18+
subtext:
19+
20+
# If your change doesn't affect end users or the exported elements of any package,
21+
# you should instead start your pull request title with [chore] or use the "Skip Changelog" label.
22+
# Optional: The change log or logs in which this entry should be included.
23+
# e.g. '[user]' or '[user, api]'
24+
# Include 'user' if the change is relevant to end users.
25+
# Include 'api' if there is a change to a library API.
26+
# Default: '[user]'
27+
change_logs: [user]

receiver/tlscheckreceiver/README.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,22 @@ By default, the TLS Check Receiver will emit a single metric, `tlscheck.time_lef
2020

2121
## Example Configuration
2222

23-
Targets are
23+
Targets are configured as either remote enpoints accessed via TCP, or PEM-encoded certificate files stored locally on disk.
2424

2525
```yaml
2626
receivers:
2727
tlscheck:
2828
targets:
29+
# Monitor a local PEM file
30+
- file_path: /etc/istio/certs/cert-chain.pem
31+
32+
# Monitor a remote endpoint
2933
- endpoint: example.com:443
34+
35+
# Monitor a local service with a custom timeout
36+
- endpoint: localhost:10901
3037
dialer:
3138
timeout: 15s
32-
- endpoint: foobar.com:8080
33-
dialer:
34-
timeout: 15s
35-
- endpoint: localhost:10901
3639
```
3740
3841
## Certificate Verification

receiver/tlscheckreceiver/config.go

Lines changed: 64 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"errors"
88
"fmt"
99
"net"
10+
"os"
11+
"path/filepath"
1012
"strconv"
1113
"strings"
1214

@@ -20,11 +22,18 @@ import (
2022
// Predefined error responses for configuration validation failures
2123
var errInvalidEndpoint = errors.New(`"endpoint" must be in the form of <hostname>:<port>`)
2224

25+
// CertificateTarget represents a target for certificate checking, which can be either
26+
// a network endpoint or a local file
27+
type CertificateTarget struct {
28+
confignet.TCPAddrConfig `mapstructure:",squash"`
29+
FilePath string `mapstructure:"file_path"`
30+
}
31+
2332
// Config defines the configuration for the various elements of the receiver agent.
2433
type Config struct {
2534
scraperhelper.ControllerConfig `mapstructure:",squash"`
2635
metadata.MetricsBuilderConfig `mapstructure:",squash"`
27-
Targets []*confignet.TCPAddrConfig `mapstructure:"targets"`
36+
Targets []*CertificateTarget `mapstructure:"targets"`
2837
}
2938

3039
func validatePort(port string) error {
@@ -38,28 +47,67 @@ func validatePort(port string) error {
3847
return nil
3948
}
4049

41-
func validateTarget(cfg *confignet.TCPAddrConfig) error {
42-
var err error
43-
44-
if cfg.Endpoint == "" {
45-
return errMissingTargets
50+
func validateTarget(ct *CertificateTarget) error {
51+
// Check that exactly one of endpoint or file_path is specified
52+
if ct.Endpoint != "" && ct.FilePath != "" {
53+
return fmt.Errorf("cannot specify both endpoint and file_path")
4654
}
47-
48-
if strings.Contains(cfg.Endpoint, "://") {
49-
return fmt.Errorf("endpoint contains a scheme, which is not allowed: %s", cfg.Endpoint)
55+
if ct.Endpoint == "" && ct.FilePath == "" {
56+
return fmt.Errorf("must specify either endpoint or file_path")
5057
}
5158

52-
_, port, parseErr := net.SplitHostPort(cfg.Endpoint)
53-
if parseErr != nil {
54-
return fmt.Errorf("%s: %w", errInvalidEndpoint.Error(), parseErr)
59+
// Validate endpoint if specified
60+
if ct.Endpoint != "" {
61+
if strings.Contains(ct.Endpoint, "://") {
62+
return fmt.Errorf("endpoint contains a scheme, which is not allowed: %s", ct.Endpoint)
63+
}
64+
65+
_, port, err := net.SplitHostPort(ct.Endpoint)
66+
if err != nil {
67+
return fmt.Errorf("%s: %w", errInvalidEndpoint.Error(), err)
68+
}
69+
70+
if err := validatePort(port); err != nil {
71+
return fmt.Errorf("%s: %w", errInvalidEndpoint.Error(), err)
72+
}
5573
}
5674

57-
portParseErr := validatePort(port)
58-
if portParseErr != nil {
59-
return fmt.Errorf("%s: %w", errInvalidEndpoint.Error(), portParseErr)
75+
// Validate file path if specified
76+
if ct.FilePath != "" {
77+
// Clean the path to handle different path separators
78+
cleanPath := filepath.Clean(ct.FilePath)
79+
80+
// Check if the path is absolute
81+
if !filepath.IsAbs(cleanPath) {
82+
return fmt.Errorf("file path must be absolute: %s", ct.FilePath)
83+
}
84+
85+
// Check if path exists and is a regular file
86+
fileInfo, err := os.Stat(cleanPath)
87+
if err != nil {
88+
if os.IsNotExist(err) {
89+
return fmt.Errorf("certificate file does not exist: %s", ct.FilePath)
90+
}
91+
return fmt.Errorf("error accessing certificate file %s: %w", ct.FilePath, err)
92+
}
93+
94+
// check if it is a directory
95+
if fileInfo.IsDir() {
96+
return fmt.Errorf("path is a directory, not a file: %s", cleanPath)
97+
}
98+
99+
// Check if it's a regular file (not a directory or special file)
100+
if !fileInfo.Mode().IsRegular() {
101+
return fmt.Errorf("certificate path is not a regular file: %s", ct.FilePath)
102+
}
103+
104+
// Check if file is readable
105+
if _, err := os.ReadFile(cleanPath); err != nil {
106+
return fmt.Errorf("certificate file is not readable: %s", ct.FilePath)
107+
}
60108
}
61109

62-
return err
110+
return nil
63111
}
64112

65113
func (cfg *Config) Validate() error {

receiver/tlscheckreceiver/config_test.go

Lines changed: 111 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ package tlscheckreceiver // import "github.com/open-telemetry/opentelemetry-coll
55

66
import (
77
"fmt"
8+
"os"
9+
"path/filepath"
810
"testing"
911
"time"
1012

@@ -14,6 +16,17 @@ import (
1416
)
1517

1618
func TestValidate(t *testing.T) {
19+
// Create a temporary certificate file for testing
20+
tmpFile, err := os.CreateTemp(t.TempDir(), "test-cert-*.pem")
21+
require.NoError(t, err)
22+
23+
// Create a temporary directory for testing
24+
tmpDir := t.TempDir()
25+
t.Cleanup(func() {
26+
tmpFile.Close()
27+
os.RemoveAll(tmpDir)
28+
})
29+
1730
testCases := []struct {
1831
desc string
1932
cfg *Config
@@ -22,99 +35,163 @@ func TestValidate(t *testing.T) {
2235
{
2336
desc: "missing targets",
2437
cfg: &Config{
25-
Targets: []*confignet.TCPAddrConfig{},
38+
Targets: []*CertificateTarget{},
2639
ControllerConfig: scraperhelper.NewDefaultControllerConfig(),
2740
},
2841
expectedErr: errMissingTargets,
2942
},
3043
{
31-
desc: "invalid endpoint",
44+
desc: "valid endpoint config",
3245
cfg: &Config{
33-
Targets: []*confignet.TCPAddrConfig{
46+
Targets: []*CertificateTarget{
3447
{
35-
Endpoint: "bad-endpoint: 12efg",
36-
DialerConfig: confignet.DialerConfig{
37-
Timeout: 12 * time.Second,
48+
TCPAddrConfig: confignet.TCPAddrConfig{
49+
Endpoint: "opentelemetry.io:443",
50+
DialerConfig: confignet.DialerConfig{
51+
Timeout: 3 * time.Second,
52+
},
53+
},
54+
},
55+
{
56+
TCPAddrConfig: confignet.TCPAddrConfig{
57+
Endpoint: "opentelemetry.io:8080",
3858
},
3959
},
4060
},
4161
ControllerConfig: scraperhelper.NewDefaultControllerConfig(),
4262
},
43-
expectedErr: fmt.Errorf("%w: %s", errInvalidEndpoint, "provided port is not a number: 12efg"),
63+
expectedErr: nil,
4464
},
4565
{
46-
desc: "invalid config with multiple targets",
66+
desc: "valid file path config",
4767
cfg: &Config{
48-
Targets: []*confignet.TCPAddrConfig{
68+
Targets: []*CertificateTarget{
4969
{
50-
Endpoint: "endpoint: 12efg",
70+
FilePath: tmpFile.Name(),
5171
},
72+
},
73+
ControllerConfig: scraperhelper.NewDefaultControllerConfig(),
74+
},
75+
expectedErr: nil,
76+
},
77+
{
78+
desc: "mixed valid config",
79+
cfg: &Config{
80+
Targets: []*CertificateTarget{
5281
{
53-
Endpoint: "https://example.com:80",
82+
TCPAddrConfig: confignet.TCPAddrConfig{
83+
Endpoint: "opentelemetry.io:443",
84+
},
85+
},
86+
{
87+
FilePath: tmpFile.Name(),
5488
},
5589
},
5690
ControllerConfig: scraperhelper.NewDefaultControllerConfig(),
5791
},
58-
expectedErr: fmt.Errorf("%w: %s", errInvalidEndpoint, `provided port is not a number: 12efg; endpoint contains a scheme, which is not allowed: https://example.com:80`),
92+
expectedErr: nil,
5993
},
6094
{
61-
desc: "port out of range",
95+
desc: "invalid endpoint",
6296
cfg: &Config{
63-
Targets: []*confignet.TCPAddrConfig{
97+
Targets: []*CertificateTarget{
6498
{
65-
Endpoint: "www.opentelemetry.io:67000",
99+
TCPAddrConfig: confignet.TCPAddrConfig{
100+
Endpoint: "bad-endpoint:12efg",
101+
},
66102
},
67103
},
68104
ControllerConfig: scraperhelper.NewDefaultControllerConfig(),
69105
},
70-
expectedErr: fmt.Errorf("%w: %s", errInvalidEndpoint, `provided port is out of valid range (1-65535): 67000`),
106+
expectedErr: fmt.Errorf("%w: provided port is not a number: 12efg", errInvalidEndpoint),
71107
},
72108
{
73-
desc: "missing port",
109+
desc: "endpoint with scheme",
74110
cfg: &Config{
75-
Targets: []*confignet.TCPAddrConfig{
111+
Targets: []*CertificateTarget{
76112
{
77-
Endpoint: "www.opentelemetry.io/docs",
113+
TCPAddrConfig: confignet.TCPAddrConfig{
114+
Endpoint: "https://example.com:443",
115+
},
78116
},
79117
},
80118
ControllerConfig: scraperhelper.NewDefaultControllerConfig(),
81119
},
82-
expectedErr: fmt.Errorf("%w: %s", errInvalidEndpoint, `address www.opentelemetry.io/docs: missing port in address`),
120+
expectedErr: fmt.Errorf("endpoint contains a scheme, which is not allowed: https://example.com:443"),
83121
},
84122
{
85-
desc: "valid config",
123+
desc: "both endpoint and file path",
86124
cfg: &Config{
87-
Targets: []*confignet.TCPAddrConfig{
125+
Targets: []*CertificateTarget{
88126
{
89-
Endpoint: "opentelemetry.io:443",
90-
DialerConfig: confignet.DialerConfig{
91-
Timeout: 3 * time.Second,
127+
TCPAddrConfig: confignet.TCPAddrConfig{
128+
Endpoint: "example.com:443",
92129
},
130+
FilePath: tmpFile.Name(),
93131
},
132+
},
133+
ControllerConfig: scraperhelper.NewDefaultControllerConfig(),
134+
},
135+
expectedErr: fmt.Errorf("cannot specify both endpoint and file_path"),
136+
},
137+
{
138+
desc: "relative file path",
139+
cfg: &Config{
140+
Targets: []*CertificateTarget{
94141
{
95-
Endpoint: "opentelemetry.io:8080",
96-
DialerConfig: confignet.DialerConfig{
97-
Timeout: 1 * time.Second,
98-
},
142+
FilePath: "cert.pem",
143+
},
144+
},
145+
ControllerConfig: scraperhelper.NewDefaultControllerConfig(),
146+
},
147+
expectedErr: fmt.Errorf("file path must be absolute: cert.pem"),
148+
},
149+
{
150+
desc: "nonexistent file",
151+
cfg: &Config{
152+
Targets: []*CertificateTarget{
153+
{
154+
FilePath: filepath.Join(tmpDir, "nonexistent.pem"),
155+
},
156+
},
157+
ControllerConfig: scraperhelper.NewDefaultControllerConfig(),
158+
},
159+
expectedErr: fmt.Errorf("certificate file does not exist"),
160+
},
161+
{
162+
desc: "directory instead of file",
163+
cfg: &Config{
164+
Targets: []*CertificateTarget{
165+
{
166+
FilePath: tmpDir,
99167
},
168+
},
169+
ControllerConfig: scraperhelper.NewDefaultControllerConfig(),
170+
},
171+
expectedErr: fmt.Errorf("path is a directory, not a file: %s", tmpDir),
172+
},
173+
{
174+
desc: "port out of range",
175+
cfg: &Config{
176+
Targets: []*CertificateTarget{
100177
{
101-
Endpoint: "111.222.33.44:10000",
102-
DialerConfig: confignet.DialerConfig{
103-
Timeout: 5 * time.Second,
178+
TCPAddrConfig: confignet.TCPAddrConfig{
179+
Endpoint: "example.com:67000",
104180
},
105181
},
106182
},
107183
ControllerConfig: scraperhelper.NewDefaultControllerConfig(),
108184
},
109-
expectedErr: nil,
185+
expectedErr: fmt.Errorf("%w: provided port is out of valid range (1-65535): 67000", errInvalidEndpoint),
110186
},
111187
}
112188

113189
for _, tc := range testCases {
114190
t.Run(tc.desc, func(t *testing.T) {
115191
actualErr := tc.cfg.Validate()
116192
if tc.expectedErr != nil {
117-
require.EqualError(t, actualErr, tc.expectedErr.Error())
193+
require.Error(t, actualErr)
194+
require.Contains(t, actualErr.Error(), tc.expectedErr.Error())
118195
} else {
119196
require.NoError(t, actualErr)
120197
}

receiver/tlscheckreceiver/documentation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,4 @@ Time in seconds until certificate expiry, as specified by `NotAfter` field in th
3232

3333
| Name | Description | Values | Enabled |
3434
| ---- | ----------- | ------ | ------- |
35-
| tlscheck.endpoint | Endpoint at which the certificate was accessed. | Any Str | true |
35+
| tlscheck.target | Endpoint or file path at which the certificate was accessed. | Any Str | true |

0 commit comments

Comments
 (0)