mirror of
https://github.com/enix/x509-certificate-exporter.git
synced 2026-08-19 03:56:21 +00:00
feat: add opt-in flag to skip symlinks
This commit is contained in:
committed by
Thibault VINCENT
parent
375572c373
commit
85c97e3837
@@ -100,7 +100,7 @@ rules:
|
||||
For advanced configuration, see the program's `--help`:
|
||||
|
||||
```
|
||||
Usage: x509-certificate-exporter [-hv] [-b value] [--debug] [-d value] [--exclude-label value] [--exclude-namespace value] [--expose-per-cert-error-metrics] [--expose-relative-metrics] [-f value] [--include-label value] [--include-namespace value] [--kubeconfig path] [-k value] [-l value] [--max-cache-duration value] [--profile] [-s value] [--trim-path-components value] [--watch-kube-secrets] [--web.config.file value] [--web.systemd-socket] [parameters ...]
|
||||
Usage: x509-certificate-exporter [-hv] [-b value] [--debug] [-d value] [--exclude-label value] [--exclude-namespace value] [--expose-per-cert-error-metrics] [--expose-relative-metrics] [-f value] [--skip-symlinks] [--include-label value] [--include-namespace value] [--kubeconfig path] [-k value] [-l value] [--max-cache-duration value] [--profile] [-s value] [--trim-path-components value] [--watch-kube-secrets] [--web.config.file value] [--web.systemd-socket] [parameters ...]
|
||||
-b, --listen-address=value
|
||||
address on which to bind and expose metrics [:9793]
|
||||
--debug enable debug mode
|
||||
@@ -146,7 +146,10 @@ Usage: x509-certificate-exporter [-hv] [-b value] [--debug] [-d value] [--exclud
|
||||
namespace and randomized to avoid massive requests.
|
||||
--profile optionally enable a pprof server to monitor cpu and memory
|
||||
usage at runtime
|
||||
-s, --secret-type=value
|
||||
--skip-symlinks
|
||||
skip symlinks when scanning files and directories. Does not
|
||||
apply to Kubernetes secrets.
|
||||
-s, --secret-type=value
|
||||
one or more kubernetes secret type & key to watch (e.g.
|
||||
"kubernetes.io/tls:tls.crt"
|
||||
--trim-path-components=value
|
||||
|
||||
@@ -37,6 +37,7 @@ func main() {
|
||||
exposeErrorMetrics := getopt.BoolLong("expose-per-cert-error-metrics", 0, "expose additionnal error metric for each certificate indicating wether it has failure(s)")
|
||||
exposeLabels := getopt.StringLong("expose-labels", 'l', "one or more comma-separated labels to enable (defaults to all if not specified)")
|
||||
profile := getopt.BoolLong("profile", 0, "optionally enable a pprof server to monitor cpu and memory usage at runtime")
|
||||
skipSymlinks := getopt.BoolLong("skip-symlinks", 0, "skip symlinks when scanning files and directories (does not apply to Kubernetes secrets)")
|
||||
|
||||
maxCacheDuration := durationFlag(0)
|
||||
getopt.FlagLong(&maxCacheDuration, "max-cache-duration", 0, "maximum cache duration for kube secrets. cache is per namespace and randomized to avoid massive requests.")
|
||||
@@ -157,6 +158,7 @@ func main() {
|
||||
KubeIncludeLabels: kubeIncludeLabels,
|
||||
KubeExcludeLabels: kubeExcludeLabels,
|
||||
KubeSecretLabels: kubeSecretLabels,
|
||||
SkipSymlinks: *skipSymlinks,
|
||||
}
|
||||
|
||||
if getopt.Lookup("expose-labels").Seen() {
|
||||
|
||||
@@ -405,6 +405,7 @@ hostPathsExporter:
|
||||
| secretsExporter.env | list | `[]` | Additional environment variables for container |
|
||||
| hostPathsExporter.annotations | object | `{}` | Additional DaemonSet annotations |
|
||||
| hostPathsExporter.debugMode | bool | `false` | Should debug messages be produced by hostPath exporters (default for all hostPathsExporter.daemonSets) |
|
||||
| hostPathsExporter.skipSymlinks | bool | `false` | Skip symlinks when scanning files and directories. Does not apply to Kubernetes secrets. |
|
||||
| hostPathsExporter.restartPolicy | string | `"Always"` | restartPolicy for Pods of hostPath exporters (default for all hostPathsExporter.daemonSets) |
|
||||
| hostPathsExporter.updateStrategy | object | `{}` | updateStrategy for DaemonSet of hostPath exporters (default for all hostPathsExporter.daemonSets) |
|
||||
| hostPathsExporter.revisionHistoryLimit | int | `nil` | Number of old ReplicaSets to retain for rollback (default for all hostPathsExporter.daemonSets) |
|
||||
|
||||
@@ -90,6 +90,9 @@ spec:
|
||||
{{- with default $.Values.hostPathsExporter.debugMode $dsDef.debugMode }}
|
||||
- --debug
|
||||
{{- end }}
|
||||
{{- if default $.Values.hostPathsExporter.skipSymlinks $dsDef.skipSymlinks }}
|
||||
- --skip-symlinks
|
||||
{{- end }}
|
||||
{{- if $.Values.exposeRelativeMetrics }}
|
||||
- --expose-relative-metrics
|
||||
{{- end }}
|
||||
|
||||
@@ -173,6 +173,8 @@ hostPathsExporter:
|
||||
annotations: {}
|
||||
# -- Should debug messages be produced by hostPath exporters (default for all hostPathsExporter.daemonSets)
|
||||
debugMode: false
|
||||
# -- Skip symlinks when scanning files and directories. Does not apply to Kubernetes secrets.
|
||||
skipSymlinks: false
|
||||
# -- restartPolicy for Pods of hostPath exporters (default for all hostPathsExporter.daemonSets)
|
||||
restartPolicy: Always
|
||||
# -- updateStrategy for DaemonSet of hostPath exporters (default for all hostPathsExporter.daemonSets)
|
||||
|
||||
+46
-2
@@ -45,6 +45,7 @@ type Exporter struct {
|
||||
ExposeErrorMetrics bool
|
||||
ExposeLabels []string
|
||||
ConfigMapKeys []string
|
||||
SkipSymlinks bool
|
||||
KubeSecretTypes []KubeSecretType
|
||||
KubeIncludeNamespaces []string
|
||||
KubeExcludeNamespaces []string
|
||||
@@ -294,14 +295,36 @@ func (exporter *Exporter) collectMatchingPaths(pattern string, format certificat
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := path.Clean(path.Join(dir, file.Name()))
|
||||
|
||||
// Check if symlink following is disabled and file is a symlink
|
||||
if exporter.SkipSymlinks {
|
||||
info, err := os.Lstat(filePath)
|
||||
if err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
slog.Info("skipping symlink", "path", filePath)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
output = append(output, &certificateRef{
|
||||
path: path.Clean(path.Join(dir, file.Name())),
|
||||
path: filePath,
|
||||
format: certificateFormatPEM,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
fullPath := path.Clean(path.Join(basepath, filepath))
|
||||
|
||||
// Check if symlink following is disabled and file is a symlink
|
||||
if exporter.SkipSymlinks {
|
||||
info, err := os.Lstat(fullPath)
|
||||
if err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
slog.Info("skipping symlink", "path", fullPath)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
output = append(output, &certificateRef{
|
||||
path: path.Clean(path.Join(basepath, filepath)),
|
||||
path: fullPath,
|
||||
format: format,
|
||||
yamlPaths: exporter.YAMLPaths,
|
||||
})
|
||||
@@ -329,6 +352,16 @@ func (exporter *Exporter) collectMatchingPaths(pattern string, format certificat
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, doublestar.ErrPatternNotExist) {
|
||||
// When skipping symlinks, a broken symlink can cause ErrPatternNotExist
|
||||
// Check if the pattern itself is a symlink that should be skipped
|
||||
if exporter.SkipSymlinks {
|
||||
fullPath := path.Clean(pattern)
|
||||
info, lstatErr := os.Lstat(fullPath)
|
||||
if lstatErr == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
slog.Info("skipping symlink", "path", fullPath)
|
||||
return output, outputErrors
|
||||
}
|
||||
}
|
||||
return nil, []error{errors.New("no files match \"" + pattern + "\"")}
|
||||
}
|
||||
|
||||
@@ -336,6 +369,17 @@ func (exporter *Exporter) collectMatchingPaths(pattern string, format certificat
|
||||
}
|
||||
|
||||
if len(output) == 0 && len(outputErrors) == 0 {
|
||||
// When skipping symlinks, the pattern might be a symlink that was skipped
|
||||
// In that case, we should return empty output without error
|
||||
if exporter.SkipSymlinks && !directories {
|
||||
fullPath := path.Clean(pattern)
|
||||
info, err := os.Lstat(fullPath)
|
||||
if err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
slog.Info("skipping symlink", "path", fullPath)
|
||||
return output, outputErrors
|
||||
}
|
||||
}
|
||||
|
||||
// the pattern evaluated to the opposite of what we want
|
||||
// i.e. we wanted files but the pattern only matched directories
|
||||
|
||||
|
||||
@@ -98,6 +98,70 @@ func TestSinglePEMBehindSymlink(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestSkipSymlinksFlag(t *testing.T) {
|
||||
_, filename, _, _ := runtime.Caller(0)
|
||||
|
||||
// Test 1: Valid symlink to certificate file - should be skipped when SkipSymlinks=false
|
||||
testRequest(t, &Exporter{
|
||||
Files: []string{path.Join(filepath.Dir(filename), "../test/link.pem")},
|
||||
SkipSymlinks: true,
|
||||
}, func(metrics []model.MetricFamily) {
|
||||
metric := getMetricsForName(metrics, "x509_cert_expired")
|
||||
assert.Len(t, metric, 0, "symlink should be skipped when SkipSymlinks=false")
|
||||
})
|
||||
|
||||
// Test 2: Broken symlink - should be skipped gracefully when SkipSymlinks=false (no error)
|
||||
testRequest(t, &Exporter{
|
||||
Files: []string{path.Join(filepath.Dir(filename), "../test/badlink.pem")},
|
||||
SkipSymlinks: true,
|
||||
}, func(metrics []model.MetricFamily) {
|
||||
metric := getMetricsForName(metrics, "x509_cert_expired")
|
||||
assert.Len(t, metric, 0, "broken symlink should be skipped when SkipSymlinks=false")
|
||||
errors := getMetricsForName(metrics, "x509_read_errors")
|
||||
assert.Len(t, errors, 1, "x509_read_errors metric should exist")
|
||||
assert.Equal(t, 0., errors[0].GetGauge().GetValue(), "no read errors should occur when not following symlinks")
|
||||
})
|
||||
|
||||
// Test 3: Broken relative symlink - should be skipped gracefully when SkipSymlinks=false
|
||||
testRequest(t, &Exporter{
|
||||
Files: []string{path.Join(filepath.Dir(filename), "../test/badlink-relative.pem")},
|
||||
SkipSymlinks: true,
|
||||
}, func(metrics []model.MetricFamily) {
|
||||
metric := getMetricsForName(metrics, "x509_cert_expired")
|
||||
assert.Len(t, metric, 0, "broken symlink should be skipped when SkipSymlinks=false")
|
||||
errors := getMetricsForName(metrics, "x509_read_errors")
|
||||
assert.Len(t, errors, 1, "x509_read_errors metric should exist")
|
||||
assert.Equal(t, 0., errors[0].GetGauge().GetValue(), "no read errors should occur when not following symlinks")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSkipSymlinksInDirectory(t *testing.T) {
|
||||
_, filename, _, _ := runtime.Caller(0)
|
||||
testDir := path.Join(filepath.Dir(filename), "../test")
|
||||
|
||||
// Test 1: Directory scan with SkipSymlinks=true (default) - should include symlink
|
||||
testRequest(t, &Exporter{
|
||||
Directories: []string{testDir},
|
||||
SkipSymlinks: false,
|
||||
}, func(metrics []model.MetricFamily) {
|
||||
metric := getMetricsForName(metrics, "x509_cert_expired")
|
||||
// Should have 6 certs: badlink-relative.pem (1), badlink.pem (1), basic.pem (1), double.pem (2), and link.pem (1)
|
||||
// Note: broken symlinks are resolved to their targets (badlink*.pem -> basic.pem)
|
||||
assert.Len(t, metric, 6, "should include symlink when SkipSymlinks=true")
|
||||
})
|
||||
|
||||
// Test 2: Directory scan with SkipSymlinks=false - should exclude symlink
|
||||
testRequest(t, &Exporter{
|
||||
Directories: []string{testDir},
|
||||
SkipSymlinks: true,
|
||||
}, func(metrics []model.MetricFamily) {
|
||||
metric := getMetricsForName(metrics, "x509_cert_expired")
|
||||
// Should have 3 certs: basic.pem (1) and double.pem (2)
|
||||
// Note: all symlinks (link.pem, badlink.pem, badlink-relative.pem) are skipped
|
||||
assert.Len(t, metric, 3, "should exclude symlink when SkipSymlinks=false")
|
||||
})
|
||||
}
|
||||
|
||||
func TestMultiplePEM(t *testing.T) {
|
||||
notBefore := time.Now()
|
||||
notAfter := notBefore.Add(time.Hour)
|
||||
|
||||
Reference in New Issue
Block a user