ctcloud: CertSpotter-backed CT cloud-resource monitor

Single stateless Go process that consumes CT logs via embedded (patched)
CertSpotter internals, classifies cloud/database/storage DNS identifiers
in-process with a zero-allocation classifier, enriches via round-robin DNS
resolvers with CNAME following, and batch-writes useful findings to PostgreSQL.

- third_party/certspotter: 2-hunk IdentifierSink patch, no stdout/JSON/files
- internal/classify: AWS/Azure/GCP/Firebase/Cloudflare R2/DigitalOcean/Supabase/OCI
- internal/dns: sharded bounded TTL cache, multi-resolver load balancing + failover
- internal/pipeline: bounded queues, dedup, direct-priority load shedding
- internal/store: pgx batch writer, Postgres CT checkpoints (no cert contents)
- internal/probe: optional non-destructive reachability checks (off by default)
- Docker: distroless non-root, read-only rootfs, no app volume, resource limits
- tests enforce 0 allocs/op on the classifier and sink hot path
This commit is contained in:
StasonJatham committed 2026-09-22 13:13:10 +02:00
1 parent 9e78a114ac
commit e8f7d6bfdf
118 files changed
+16473

No files matched your search

@@ -0,0 +1 @@
/certspotter-authorize
@@ -0,0 +1,202 @@
// Copyright (C) 2026 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/pem"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"runtime/debug"
"strconv"
"strings"
"software.sslmate.com/src/certspotter"
)
var programName = os.Args[0]
var Version = "unknown"
var Source = "unknown"
func certspotterVersion() (string, string) {
if buildinfo, ok := debug.ReadBuildInfo(); ok && strings.HasPrefix(buildinfo.Main.Version, "v") {
return strings.TrimPrefix(buildinfo.Main.Version, "v"), buildinfo.Main.Path
} else {
return Version, Source
}
}
func homedir() string {
homedir, err := os.UserHomeDir()
if err != nil {
panic(fmt.Errorf("unable to determine home directory: %w", err))
}
return homedir
}
func startedBySupervisor() bool {
return os.Getenv("SYSTEMD_EXEC_PID") == strconv.Itoa(os.Getpid())
}
func defaultStateDir() string {
if envVar := os.Getenv("CERTSPOTTER_STATE_DIR"); envVar != "" {
return envVar
} else if envVar := os.Getenv("STATE_DIRECTORY"); envVar != "" && startedBySupervisor() {
return envVar
} else {
return filepath.Join(homedir(), ".certspotter")
}
}
func fileExists(filename string) bool {
_, err := os.Lstat(filename)
return err == nil
}
func readCertFile(path string) ([]byte, error) {
if path == "-" {
return io.ReadAll(os.Stdin)
} else {
return os.ReadFile(path)
}
}
func parseCertificate(certBytes []byte) ([]byte, error) {
block, _ := pem.Decode(certBytes)
if block != nil {
if block.Type == "CERTIFICATE" {
return block.Bytes, nil
}
return nil, fmt.Errorf("PEM block type is %q, expected CERTIFICATE", block.Type)
}
return nil, fmt.Errorf("no PEM data found")
}
func computeTBSHash(certDER []byte) ([32]byte, error) {
certInfo, err := certspotter.MakeCertInfoFromRawCert(certDER)
if err != nil {
return [32]byte{}, fmt.Errorf("error parsing certificate: %w", err)
}
precertTBS, err := certspotter.ReconstructPrecertTBS(certInfo.TBS)
if err != nil {
return [32]byte{}, fmt.Errorf("error reconstructing precertificate TBSCertificate: %w", err)
}
return sha256.Sum256(precertTBS.Raw), nil
}
func createNotifiedMarker(stateDir string, tbsHash [32]byte) (string, error) {
tbsHex := hex.EncodeToString(tbsHash[:])
certsDir := filepath.Join(stateDir, "certs")
tbsDir := filepath.Join(certsDir, tbsHex[0:2])
notifiedPath := filepath.Join(tbsDir, "."+tbsHex+".notified")
// Check if already notified
if fileExists(notifiedPath) {
return notifiedPath, nil
}
// Create certs directory if needed
if err := os.Mkdir(certsDir, 0777); err != nil && !errors.Is(err, fs.ErrExist) {
return "", fmt.Errorf("error creating certs directory: %w", err)
}
// Create TBS-specific subdirectory if needed
if err := os.Mkdir(tbsDir, 0777); err != nil && !errors.Is(err, fs.ErrExist) {
return "", fmt.Errorf("error creating directory: %w", err)
}
// Create marker file
if err := os.WriteFile(notifiedPath, nil, 0666); err != nil {
return "", fmt.Errorf("error creating marker file: %w", err)
}
return notifiedPath, nil
}
func main() {
version, source := certspotterVersion()
var flags struct {
stateDir string
printhash bool
version bool
}
flag.StringVar(&flags.stateDir, "state_dir", defaultStateDir(), "State directory used by certspotter")
flag.BoolVar(&flags.printhash, "printhash", false, "Instead of authorizing certificate, print its TBS hash")
flag.BoolVar(&flags.version, "version", false, "Print version and exit")
flag.Parse()
if flags.version {
fmt.Fprintf(os.Stdout, "certspotter-authorize version %s (%s)\n", version, source)
os.Exit(0)
}
args := flag.Args()
if len(args) == 0 {
fmt.Fprintf(os.Stderr, "Usage: %s [options] -|CERTFILE...\n", programName)
fmt.Fprintf(os.Stderr, "Purpose: suppress future certspotter notifications for a certificate and its corresponding precertificate.\n")
fmt.Fprintf(os.Stderr, "Options:\n")
flag.PrintDefaults()
os.Exit(2)
}
if len(args) > 1 {
for _, arg := range args {
if arg == "-" {
fmt.Fprintf(os.Stderr, "%s: '-' must be the only argument when used\n", programName)
os.Exit(2)
}
}
}
for _, certPath := range args {
certBytes, err := readCertFile(certPath)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: error reading certificate: %s\n", programName, err)
os.Exit(1)
}
certDER, err := parseCertificate(certBytes)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %s: %s\n", programName, certPath, err)
os.Exit(1)
}
tbsHash, err := computeTBSHash(certDER)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %s: %s\n", programName, certPath, err)
os.Exit(1)
}
if flags.printhash {
if certPath == "-" {
fmt.Println(hex.EncodeToString(tbsHash[:]))
} else {
fmt.Printf("%s %s\n", hex.EncodeToString(tbsHash[:]), certPath)
}
} else {
_, err = createNotifiedMarker(flags.stateDir, tbsHash)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %s\n", programName, err)
os.Exit(1)
}
}
}
os.Exit(0)
}
@@ -0,0 +1,190 @@
// Copyright (C) 2026 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
package main
import (
"bytes"
"encoding/hex"
"os"
"path/filepath"
"testing"
)
const testCertPEM = `
-----BEGIN CERTIFICATE-----
MIIGuzCCBSOgAwIBAgIRANubk4g/6c+TF8jITzhFX44wDQYJKoZIhvcNAQELBQAw
YDELMAkGA1UEBhMCR0IxGDAWBgNVBAoTD1NlY3RpZ28gTGltaXRlZDE3MDUGA1UE
AxMuU2VjdGlnbyBQdWJsaWMgU2VydmVyIEF1dGhlbnRpY2F0aW9uIENBIERWIFIz
NjAeFw0yNTEyMDIwMDAwMDBaFw0yNjExMjEyMzU5NTlaMBYxFDASBgNVBAMTC3Nz
bG1hdGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA235/3Y/E
4yPAHPa37C7Fgp7KPVjjuTB5vKV9nYIJzfp7NgvDBlf7k5bZFCSsSIj2txhL0hzX
Bwvmy7u7CYR7CApr2Rx2UPOl7Gmlt/DmtfyKac8Iunn2ozuGZDtxq19Go4NL9jl9
e9O3H/lcL/ZFqzbUNlKIOfkOYkOxM3qpQXHTXuhkeI2MJO/S4wX8y8/8uhArWQ9e
h/YrtJlO9fla60kLUlQF7mtJTc+0oB3+N4eF5t2a8Pav00T6lVvH8hMhbY0nZ/tB
CD6/I6yelh8cP094VRJEGWs+zcEuXpz4FsZggkhF/l+AhQ+DfgxZhno4M60kBKC8
Un1BTGX5TjfjJQIDAQABo4IDODCCAzQwHwYDVR0jBBgwFoAUaMASFhgOr872h6Yy
V6NGUV3LBycwHQYDVR0OBBYEFKg2kl8xIzdSxjOEAzlNpdpigAazMA4GA1UdDwEB
/wQEAwIFoDAMBgNVHRMBAf8EAjAAMBMGA1UdJQQMMAoGCCsGAQUFBwMBMEkGA1Ud
IARCMEAwNAYLKwYBBAGyMQECAgcwJTAjBggrBgEFBQcCARYXaHR0cHM6Ly9zZWN0
aWdvLmNvbS9DUFMwCAYGZ4EMAQIBMIGEBggrBgEFBQcBAQR4MHYwTwYIKwYBBQUH
MAKGQ2h0dHA6Ly9jcnQuc2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY1NlcnZlckF1
dGhlbnRpY2F0aW9uQ0FEVlIzNi5jcnQwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3Nw
LnNlY3RpZ28uY29tMIIBfQYKKwYBBAHWeQIEAgSCAW0EggFpAWcAdQDXbX0Q0af1
d8LH6V/XAL/5gskzWmXh0LMBcxfAyMVpdwAAAZrg2UujAAAEAwBGMEQCIApVmNqo
gzJBNbcVXezO5sSvOFE5FaVZVz/eaqnCbG+2AiAT/A7XPtOYHwsE0wmTUBCTV/0l
bF7lk573b3rNtBvP3QB2AK9niDtXsE7dj6bZfvYuqOuBCsdxYPAkXlXWDC/nhYc6
AAABmuDZTFoAAAQDAEcwRQIgODruJKtbjW1QJcQP7ARZAw5FgChfI599pJBA2bbQ
suICIQCskIUnzQCD6taycnQCN3zpu+rsz3Vd4AsMeFDM/cDJ5AB2AKyrMHBs6+yE
MfQT0vSRXxEeQiRDsfKmjE88KzunHgLDAAABmuDZS5kAAAQDAEcwRQIgD9IQCPTc
N88jbz5DUILwmDruTo411Ep5M2ZryNjBkywCIQCFwIyqGZEd+PiFv4l+5LOV3yDW
/zUuimFUoAJH5OIiNDBsBgNVHREEZTBjggtzc2xtYXRlLmNvbYIbKi5odHRwLWFw
cHJvdmFsLnNzbG1hdGUuY29tghFjZXJ0cy5zc2xtYXRlLmNvbYITY29uc29sZS5z
c2xtYXRlLmNvbYIPd3d3LnNzbG1hdGUuY29tMA0GCSqGSIb3DQEBCwUAA4IBgQBR
Pjx14qo9PiYYEE1695CHdctA6up8L+n0MRapZcxALN/cetfGeoR00ZEH+7b1X7Ma
F9GGv1OtJXDoCySlAsdwFKHYtKhrUYRuQXdKGkTjdMzKO/+5kXeZIqgsCR10j8nr
Zq0Zcg2ply4j03/0y7+8ZNC1Erp4DB1Tq7ybgXnyURaNQTHsSkDoxMT/bWIrhGD0
C8kN/ExkFvOBQlzdbuwo2d3v0zSM4mYmnqUhUYHprZllOziYgxIqjM/7mfnDkVAi
ov8yNJtn6EPt1wt6Oo3fC+Ft1T/kbSxeZbqWf3Zgbon5ijmNz+xqkb8br2+JdzM+
8gEIqO6mNoMl0tayzb4a5KDaHxhczMGB3ggBwpVcdLtYBBa41thrgRP0VARqFTFG
IIkC9gPMjScf+uv9CQPsNk3kFI8vN4T3x4/g54N8Mc3M4JxvLaOsBj8dMeyq7v2p
1zE9WRngMUWuPgx0O94c0Pteumg/+pSGVeRapIuYZxXvkmLJ5wmwgYepix+cw1w=
-----END CERTIFICATE-----
`
func TestComputeTBSHash(t *testing.T) {
certDER, err := parseCertificate([]byte(testCertPEM))
if err != nil {
t.Fatalf("parseCertificate failed: %v", err)
}
tbsHash, err := computeTBSHash(certDER)
if err != nil {
t.Fatalf("computeTBSHash failed: %v", err)
}
if expected := [...]byte{0x3c, 0xf6, 0xb2, 0x44, 0xc2, 0x95, 0x85, 0xdb, 0xfb, 0xfd, 0x42, 0x0a, 0x6a, 0x4c, 0x62, 0xf7, 0x96, 0x8f, 0xa9, 0x05, 0xb4, 0xd6, 0xa4, 0xf5, 0x9d, 0x4d, 0x3b, 0xc9, 0xfa, 0xcb, 0x0c, 0xc8}; expected != tbsHash {
t.Fatalf("computeTBSHash returned %x; expected %x", tbsHash, expected)
}
}
func TestCreateNotifiedMarker(t *testing.T) {
stateDir := t.TempDir()
certDER, err := parseCertificate([]byte(testCertPEM))
if err != nil {
t.Fatalf("parseCertificate failed: %v", err)
}
tbsHash, err := computeTBSHash(certDER)
if err != nil {
t.Fatalf("computeTBSHash failed: %v", err)
}
// First call should create the marker
notifiedPath, err := createNotifiedMarker(stateDir, tbsHash)
if err != nil {
t.Fatalf("createNotifiedMarker failed: %v", err)
}
// Verify marker file exists
if !fileExists(notifiedPath) {
t.Fatalf("marker file does not exist: %s", notifiedPath)
}
// Verify path structure is correct
tbsHex := hex.EncodeToString(tbsHash[:])
expectedPath := filepath.Join(stateDir, "certs", tbsHex[0:2], "."+tbsHex+".notified")
if notifiedPath != expectedPath {
t.Fatalf("unexpected marker path: got %s, expected %s", notifiedPath, expectedPath)
}
// Second call should succeed (idempotency)
notifiedPath2, err := createNotifiedMarker(stateDir, tbsHash)
if err != nil {
t.Fatalf("createNotifiedMarker second call failed: %v", err)
}
if notifiedPath != notifiedPath2 {
t.Fatalf("second call returned different path: got %s, expected %s", notifiedPath2, notifiedPath)
}
}
func TestReadCertFile(t *testing.T) {
// Test reading from a file
tmpDir := t.TempDir()
certPath := filepath.Join(tmpDir, "cert.pem")
if err := os.WriteFile(certPath, []byte(testCertPEM), 0644); err != nil {
t.Fatalf("failed to write test cert: %v", err)
}
certBytes, err := readCertFile(certPath)
if err != nil {
t.Fatalf("readCertFile failed: %v", err)
}
if !bytes.Equal(certBytes, []byte(testCertPEM)) {
t.Fatal("readCertFile returned different content")
}
}
func TestFileExists(t *testing.T) {
tmpDir := t.TempDir()
// Test with non-existent file
if fileExists(filepath.Join(tmpDir, "nonexistent")) {
t.Fatal("fileExists returned true for non-existent file")
}
// Test with existing file
existingFile := filepath.Join(tmpDir, "existing")
if err := os.WriteFile(existingFile, []byte("test"), 0644); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
if !fileExists(existingFile) {
t.Fatal("fileExists returned false for existing file")
}
}
func TestEndToEnd(t *testing.T) {
stateDir := t.TempDir()
certDER, err := parseCertificate([]byte(testCertPEM))
if err != nil {
t.Fatalf("parseCertificate failed: %v", err)
}
tbsHash, err := computeTBSHash(certDER)
if err != nil {
t.Fatalf("computeTBSHash failed: %v", err)
}
notifiedPath, err := createNotifiedMarker(stateDir, tbsHash)
if err != nil {
t.Fatalf("createNotifiedMarker failed: %v", err)
}
// Verify the marker file structure matches what monitor/fsstate.go expects
tbsHex := hex.EncodeToString(tbsHash[:])
expectedDir := filepath.Join(stateDir, "certs", tbsHex[0:2])
expectedFile := filepath.Join(expectedDir, "."+tbsHex+".notified")
if notifiedPath != expectedFile {
t.Fatalf("unexpected marker path: got %s, expected %s", notifiedPath, expectedFile)
}
if !fileExists(expectedFile) {
t.Fatalf("marker file does not exist: %s", expectedFile)
}
// Verify file is empty (as expected by certspotter)
stat, err := os.Stat(expectedFile)
if err != nil {
t.Fatalf("failed to stat marker file: %v", err)
}
if stat.Size() != 0 {
t.Fatalf("marker file should be empty, but has size %d", stat.Size())
}
}
@@ -0,0 +1 @@
/certspotter-metrics
+245
View File
@@ -0,0 +1,245 @@
// Copyright (C) 2026 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
package main
import (
"bufio"
"context"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"runtime/debug"
"slices"
"strconv"
"strings"
"software.sslmate.com/src/certspotter/monitor"
)
var programName = os.Args[0]
var Version = "unknown"
var Source = "unknown"
func certspotterVersion() (string, string) {
if buildinfo, ok := debug.ReadBuildInfo(); ok && strings.HasPrefix(buildinfo.Main.Version, "v") {
return strings.TrimPrefix(buildinfo.Main.Version, "v"), buildinfo.Main.Path
} else {
return Version, Source
}
}
func homedir() string {
homedir, err := os.UserHomeDir()
if err != nil {
panic(fmt.Errorf("unable to determine home directory: %w", err))
}
return homedir
}
func startedBySupervisor() bool {
return os.Getenv("SYSTEMD_EXEC_PID") == strconv.Itoa(os.Getpid())
}
func defaultStateDir() string {
if envVar := os.Getenv("CERTSPOTTER_STATE_DIR"); envVar != "" {
return envVar
} else if envVar := os.Getenv("STATE_DIRECTORY"); envVar != "" && startedBySupervisor() {
return envVar
} else {
return filepath.Join(homedir(), ".certspotter")
}
}
// logMetrics is the set of metric values for a single log.
type logMetrics struct {
logID monitor.LogID
maxSTHExists bool
maxSTHSize uint64
maxSTHTimestamp uint64
downloadPosition uint64
verifiedPosition uint64
verifiedSTHExists bool
verifiedSTHTimestamp uint64
unverifiedSTHs uint64
malformedEntries uint64
healthCheckFailures uint64
}
// metricFamily describes one of the metrics output by certspotter-metrics.
// All metrics are gauges labeled with the ID of the log they pertain to.
type metricFamily struct {
name string
help string
value func(*logMetrics) (uint64, bool)
}
var metricFamilies = []metricFamily{
{
name: "certspotter_log_max_sth_size",
help: "Tree size of largest known STH.",
value: func(m *logMetrics) (uint64, bool) { return m.maxSTHSize, m.maxSTHExists },
},
{
name: "certspotter_log_max_sth_timestamp",
help: "Timestamp (in milliseconds since the Epoch) of largest known STH.",
value: func(m *logMetrics) (uint64, bool) { return m.maxSTHTimestamp, m.maxSTHExists },
},
{
name: "certspotter_log_download_position",
help: "Number of entries downloaded from the log, including entries not yet verified.",
value: func(m *logMetrics) (uint64, bool) { return m.downloadPosition, true },
},
{
name: "certspotter_log_verified_position",
help: "Number of entries downloaded from the log and verified against an STH.",
value: func(m *logMetrics) (uint64, bool) { return m.verifiedPosition, true },
},
{
name: "certspotter_log_verified_sth_timestamp",
help: "Timestamp (in milliseconds since the Epoch) of the largest verified STH (the STH whose size is verified_position).",
value: func(m *logMetrics) (uint64, bool) { return m.verifiedSTHTimestamp, m.verifiedSTHExists },
},
{
name: "certspotter_log_unverified_sths",
help: "Number of STHs pending verification.",
value: func(m *logMetrics) (uint64, bool) { return m.unverifiedSTHs, true },
},
{
name: "certspotter_log_malformed_entries",
help: "Number of malformed entries recorded for the log.",
value: func(m *logMetrics) (uint64, bool) { return m.malformedEntries, true },
},
{
name: "certspotter_log_health_check_failures",
help: "Number of health check failures recorded for the log.",
value: func(m *logMetrics) (uint64, bool) { return m.healthCheckFailures, true },
},
}
// gatherMetrics computes the metrics for every log that has a state directory.
// The returned slice is sorted by log ID.
func gatherMetrics(ctx context.Context, state *monitor.FilesystemState) ([]logMetrics, error) {
logIDs, err := state.ListLogs(ctx)
if err != nil {
return nil, fmt.Errorf("error listing logs in state directory: %w", err)
}
slices.SortFunc(logIDs, monitor.LogID.Compare)
allMetrics := make([]logMetrics, 0, len(logIDs))
for _, logID := range logIDs {
m := logMetrics{logID: logID}
logState, err := state.LoadLogState(ctx, logID)
if err != nil {
return nil, fmt.Errorf("error loading state of log %s: %w", logID.Base64String(), err)
}
largestSTH, err := state.LoadLargestSTH(ctx, logID)
if err != nil {
return nil, fmt.Errorf("error loading STHs of log %s: %w", logID.Base64String(), err)
}
if largestSTH != nil {
m.maxSTHExists = true
m.maxSTHSize = largestSTH.TreeSize
m.maxSTHTimestamp = largestSTH.Timestamp
} else if logState != nil && logState.VerifiedSTH != nil {
m.maxSTHExists = true
m.maxSTHSize = logState.VerifiedSTH.TreeSize
m.maxSTHTimestamp = logState.VerifiedSTH.Timestamp
}
if logState != nil {
if logState.DownloadPosition != nil {
m.downloadPosition = logState.DownloadPosition.Size()
}
if logState.VerifiedPosition != nil {
m.verifiedPosition = logState.VerifiedPosition.Size()
}
if logState.VerifiedSTH != nil {
m.verifiedSTHExists = true
m.verifiedSTHTimestamp = logState.VerifiedSTH.Timestamp
}
}
if unverifiedSTHs, err := state.CountSTHs(ctx, logID); err != nil {
return nil, fmt.Errorf("error counting STHs of log %s: %w", logID.Base64String(), err)
} else {
m.unverifiedSTHs = uint64(unverifiedSTHs)
}
if malformedEntries, err := state.CountMalformedEntries(ctx, logID); err != nil {
return nil, fmt.Errorf("error counting malformed entries of log %s: %w", logID.Base64String(), err)
} else {
m.malformedEntries = uint64(malformedEntries)
}
if healthCheckFailures, err := state.CountHealthCheckFailures(ctx, logID); err != nil {
return nil, fmt.Errorf("error counting health check failures of log %s: %w", logID.Base64String(), err)
} else {
m.healthCheckFailures = uint64(healthCheckFailures)
}
allMetrics = append(allMetrics, m)
}
return allMetrics, nil
}
// writeMetrics writes allMetrics to w in the Prometheus text exposition format.
func writeMetrics(w io.Writer, allMetrics []logMetrics) error {
buf := bufio.NewWriter(w)
for _, family := range metricFamilies {
if _, err := fmt.Fprintf(buf, "# HELP %s %s\n", family.name, family.help); err != nil {
return err
}
if _, err := fmt.Fprintf(buf, "# TYPE %s gauge\n", family.name); err != nil {
return err
}
for i := range allMetrics {
value, include := family.value(&allMetrics[i])
if !include {
continue
}
if _, err := fmt.Fprintf(buf, "%s{log_id=\"%s\"} %d\n", family.name, allMetrics[i].logID.Base64String(), value); err != nil {
return err
}
}
}
return buf.Flush()
}
func main() {
version, source := certspotterVersion()
var flags struct {
stateDir string
version bool
}
flag.StringVar(&flags.stateDir, "state_dir", defaultStateDir(), "Directory where certspotter stores state. This should be the same directory used by certspotter(8)")
flag.BoolVar(&flags.version, "version", false, "Print version and exit")
flag.Parse()
if flags.version {
fmt.Fprintf(os.Stdout, "certspotter-metrics version %s (%s)\n", version, source)
os.Exit(0)
}
state := &monitor.FilesystemState{
StateDir: flags.stateDir,
}
allMetrics, err := gatherMetrics(context.Background(), state)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %s\n", programName, err)
os.Exit(1)
}
if err := writeMetrics(os.Stdout, allMetrics); err != nil {
fmt.Fprintf(os.Stderr, "%s: error writing metrics: %s\n", programName, err)
os.Exit(1)
}
}
@@ -0,0 +1 @@
/certspotter
+290
View File
@@ -0,0 +1,290 @@
// Copyright (C) 2016, 2023 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"io/fs"
"os"
"os/signal"
"path/filepath"
"runtime"
"runtime/debug"
"strconv"
"strings"
"syscall"
"time"
"software.sslmate.com/src/certspotter/ctclient"
"software.sslmate.com/src/certspotter/loglist"
"software.sslmate.com/src/certspotter/monitor"
)
var programName = os.Args[0]
var Version = "unknown"
var Source = "unknown"
const defaultLogList = "https://loglist.certspotter.org/monitor.json"
func certspotterVersion() (string, string) {
if buildinfo, ok := debug.ReadBuildInfo(); ok && strings.HasPrefix(buildinfo.Main.Version, "v") {
return strings.TrimPrefix(buildinfo.Main.Version, "v"), buildinfo.Main.Path
} else {
return Version, Source
}
}
func fileExists(filename string) bool {
_, err := os.Lstat(filename)
return err == nil
}
func homedir() string {
homedir, err := os.UserHomeDir()
if err != nil {
panic(fmt.Errorf("unable to determine home directory: %w", err))
}
return homedir
}
func userCacheDir() string {
userCacheDir, err := os.UserCacheDir()
if err != nil {
panic(fmt.Errorf("unable to determine user cache directory: %w", err))
}
return userCacheDir
}
func startedBySupervisor() bool {
return os.Getenv("SYSTEMD_EXEC_PID") == strconv.Itoa(os.Getpid())
}
func defaultStateDir() string {
if envVar := os.Getenv("CERTSPOTTER_STATE_DIR"); envVar != "" {
return envVar
} else if envVar := os.Getenv("STATE_DIRECTORY"); envVar != "" && startedBySupervisor() {
return envVar
} else {
return filepath.Join(homedir(), ".certspotter")
}
}
func defaultConfigDir() string {
if envVar := os.Getenv("CERTSPOTTER_CONFIG_DIR"); envVar != "" {
return envVar
} else if envVar := os.Getenv("CONFIGURATION_DIRECTORY"); envVar != "" && startedBySupervisor() {
return envVar
} else {
return filepath.Join(homedir(), ".certspotter")
}
}
func defaultCacheDir() string {
if envVar := os.Getenv("CERTSPOTTER_CACHE_DIR"); envVar != "" {
return envVar
} else if envVar := os.Getenv("CACHE_DIRECTORY"); envVar != "" && startedBySupervisor() {
return envVar
} else {
return filepath.Join(userCacheDir(), "certspotter")
}
}
func defaultWatchListPath() string {
return filepath.Join(defaultConfigDir(), "watchlist")
}
func defaultWatchListPathIfExists() string {
if fileExists(defaultWatchListPath()) {
return defaultWatchListPath()
} else {
return ""
}
}
func defaultScriptDir() string {
return filepath.Join(defaultConfigDir(), "hooks.d")
}
func defaultEmailFile() string {
return filepath.Join(defaultConfigDir(), "email_recipients")
}
func simplifyError(err error) error {
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
return pathErr.Err
}
return err
}
func readWatchListFile(filename string) (monitor.WatchList, error) {
file, err := os.Open(filename)
if err != nil {
return nil, simplifyError(err)
}
defer file.Close()
return monitor.ReadWatchList(file)
}
func readEmailFile(filename string) ([]string, error) {
file, err := os.Open(filename)
if err != nil {
return nil, simplifyError(err)
}
defer file.Close()
var emails []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
emails = append(emails, line)
}
return emails, scanner.Err()
}
func appendFunc(slice *[]string) func(string) error {
return func(value string) error {
*slice = append(*slice, value)
return nil
}
}
func main() {
version, source := certspotterVersion()
ctclient.UserAgent = fmt.Sprintf("certspotter/%s (%s; %s; %s; %s; +https://github.com/SSLMate/certspotter)", version, source, runtime.Version(), runtime.GOOS, runtime.GOARCH)
loglist.UserAgent = ctclient.UserAgent
var flags struct {
email []string
healthcheck time.Duration
logs string
noSave bool
script string
startAtEnd bool
stateDir string
stdout bool
verbose bool
version bool
watchlist string
}
flag.Func("email", "Email address to contact when matching certificate is discovered (repeatable)", appendFunc(&flags.email))
flag.DurationVar(&flags.healthcheck, "healthcheck", 24*time.Hour, "How frequently to perform a health check")
flag.StringVar(&flags.logs, "logs", defaultLogList, "File path or URL of JSON list of logs to monitor")
flag.BoolVar(&flags.noSave, "no_save", false, "Do not save a copy of matching certificates in state directory")
flag.StringVar(&flags.script, "script", "", "Program to execute when a matching certificate is discovered")
flag.BoolVar(&flags.startAtEnd, "start_at_end", false, "Start monitoring new logs from the end rather than the beginning (saves considerable bandwidth)")
flag.StringVar(&flags.stateDir, "state_dir", defaultStateDir(), "Directory for storing log position and discovered certificates")
flag.BoolVar(&flags.stdout, "stdout", false, "Write matching certificates to stdout")
flag.BoolVar(&flags.verbose, "verbose", false, "Print detailed information about certspotter's operation to stderr")
flag.BoolVar(&flags.version, "version", false, "Print version and exit")
flag.StringVar(&flags.watchlist, "watchlist", defaultWatchListPathIfExists(), "File containing domain names to watch")
flag.Parse()
if flags.version {
fmt.Fprintf(os.Stdout, "certspotter version %s (%s)\n", version, source)
if flags.verbose {
if buildinfo, ok := debug.ReadBuildInfo(); ok {
fmt.Fprint(os.Stdout, buildinfo)
} else {
fmt.Fprint(os.Stdout, "build info not available\n")
}
}
os.Exit(0)
}
if flags.watchlist == "" {
fmt.Fprintf(os.Stderr, "%s: watch list not found: please create %s or specify alternative path using -watchlist\n", programName, defaultWatchListPath())
os.Exit(2)
}
fsstate := &monitor.FilesystemState{
StateDir: flags.stateDir,
CacheDir: defaultCacheDir(),
SaveCerts: !flags.noSave,
Script: flags.script,
ScriptDir: defaultScriptDir(),
Email: flags.email,
Stdout: flags.stdout,
}
config := &monitor.Config{
LogListSource: flags.logs,
State: fsstate,
StartAtEnd: flags.startAtEnd,
Verbose: flags.verbose,
HealthCheckInterval: flags.healthcheck,
}
emailFileExists := false
if emailRecipients, err := readEmailFile(defaultEmailFile()); err == nil {
emailFileExists = true
fsstate.Email = append(fsstate.Email, emailRecipients...)
} else if !errors.Is(err, fs.ErrNotExist) {
fmt.Fprintf(os.Stderr, "%s: error reading email recipients file %q: %s\n", programName, defaultEmailFile(), err)
os.Exit(1)
}
if len(fsstate.Email) == 0 && !emailFileExists && fsstate.Script == "" && !fileExists(fsstate.ScriptDir) && fsstate.Stdout == false {
fmt.Fprintf(os.Stderr, "%s: no notification methods were specified\n", programName)
fmt.Fprintf(os.Stderr, "Please specify at least one of the following notification methods:\n")
fmt.Fprintf(os.Stderr, " - Place one or more email addresses in %s (one address per line)\n", defaultEmailFile())
fmt.Fprintf(os.Stderr, " - Place one or more executable scripts in the %s directory\n", fsstate.ScriptDir)
fmt.Fprintf(os.Stderr, " - Specify an email address using the -email flag\n")
fmt.Fprintf(os.Stderr, " - Specify the path to an executable script using the -script flag\n")
fmt.Fprintf(os.Stderr, " - Specify the -stdout flag\n")
os.Exit(2)
}
if flags.watchlist == "-" {
watchlist, err := monitor.ReadWatchList(os.Stdin)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: error reading watchlist from standard in: %s\n", programName, err)
os.Exit(1)
}
config.WatchList = watchlist
} else {
watchlist, err := readWatchListFile(flags.watchlist)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: error reading watchlist from %q: %s\n", programName, flags.watchlist, err)
os.Exit(1)
}
config.WatchList = watchlist
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
ticker := time.NewTicker(24*time.Hour)
defer ticker.Stop()
for {
fsstate.PruneOldErrors()
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
if startedBySupervisor() {
// Prevent any process that we spawn from erroneously writing to our directories
os.Unsetenv("CACHE_DIRECTORY")
os.Unsetenv("CONFIGURATION_DIRECTORY")
os.Unsetenv("STATE_DIRECTORY")
}
if err := monitor.Run(ctx, config); ctx.Err() == context.Canceled && errors.Is(err, context.Canceled) {
if flags.verbose {
fmt.Fprintf(os.Stderr, "%s: exiting due to SIGINT or SIGTERM\n", programName)
}
os.Exit(0)
} else {
fmt.Fprintf(os.Stderr, "%s: %s\n", programName, err)
os.Exit(1)
}
}