cert-status-exporter/main.go

222 lines
4.7 KiB
Go

package main
import (
"crypto/tls"
"errors"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/urfave/cli/v2"
log "github.com/sirupsen/logrus"
)
type CertificateMetrics struct {
Domain string
Port int
ALPN []string
ExpiryIn prometheus.Gauge
IsValid prometheus.Gauge
}
func NewCertificateMetrics(domain string, port int, alpn []string, name string) CertificateMetrics {
metrics := CertificateMetrics{
Domain: domain,
Port: port,
ALPN: alpn,
ExpiryIn: prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "cert_status_expiry_in",
ConstLabels: prometheus.Labels{"name": name},
},
),
IsValid: prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "cert_status_is_valid",
ConstLabels: prometheus.Labels{"name": name},
},
),
}
// Default values
metrics.ExpiryIn.Set(0)
metrics.IsValid.Set(0)
return metrics
}
func (c *CertificateMetrics) register(registry *prometheus.Registry) {
registry.MustRegister(
c.ExpiryIn,
c.IsValid,
)
}
func (c *CertificateMetrics) checkTls() {
conn, err := tls.Dial("tcp", c.Domain+":"+fmt.Sprint(c.Port), &tls.Config{
NextProtos: c.ALPN,
})
if err != nil {
log.Debugf("Failed to dial %s:%d@%s using ALPN %v: %v", c.Domain, c.Port, "tcp", c.ALPN, err)
c.IsValid.Set(0)
return
}
now := time.Now()
leafCert := conn.ConnectionState().PeerCertificates[0]
invalidIn := leafCert.NotAfter.Sub(now)
c.ExpiryIn.Set(invalidIn.Hours() / 24)
if leafCert.NotAfter.Compare(now) == -1 {
log.Debugf("leafCert.NotAfter (%v) < now (%v)!!", leafCert.NotAfter, now)
c.IsValid.Set(0)
} else {
if err := leafCert.VerifyHostname(c.Domain); err != nil {
log.Debugf("Peer cert does not verify domain '%s'", c.Domain)
c.IsValid.Set(0)
} else {
c.IsValid.Set(1)
}
}
}
func updateMetrics(metrics *[]CertificateMetrics, lastUpdated prometheus.Gauge) {
log.Debugf("Updating metrics for %d domains...", len(*metrics))
lastUpdated.SetToCurrentTime()
for _, metric := range *metrics {
metric.checkTls()
}
log.Debugf("Update done")
}
func run(ctx *cli.Context) error {
debug := ctx.Bool("debug")
host := ctx.String("host")
port := ctx.Int("port")
domains := ctx.StringSlice("domain")
if debug {
log.SetLevel(log.DebugLevel)
}
// Setup metrics
registry := prometheus.NewRegistry()
lastUpdatedMetric := prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "certs_status_last_updated",
},
)
registry.MustRegister(lastUpdatedMetric)
metrics := make([]CertificateMetrics, 0)
for _, d := range domains {
log.Debugf("Parsing '%s'...", d)
parts := strings.Split(d, ":")
if len(parts) < 3 {
log.Errorf("Invalid domain format for '%s'", d)
return errors.New("Invalid domain format: Expects <domain>:<port>:<alpn>")
}
name := ""
if len(parts) == 4 {
name = parts[3]
} else {
name = parts[0]
}
port, err := strconv.Atoi(parts[1])
if err != nil {
log.Errorf("Failed to parse port of '%s'", d)
return err
}
// Create the metric, and register it
// TODO: Make this prettier
alpn := strings.Split(parts[2], ";")
log.Debugf("Registering: domain='%s' port='%d' alpn=%v name='%s'", parts[0], port, alpn, name)
metric := NewCertificateMetrics(
parts[0],
port,
alpn,
name,
)
metric.register(registry)
metrics = append(metrics, metric)
}
log.Infof("Parsed %d domain(s)", len(domains))
// Setup the task scheduler
scheduler, err := gocron.NewScheduler()
if err != nil {
log.Error("Failed to set up task scheduler")
return err
}
_, err = scheduler.NewJob(
gocron.DurationJob(24*time.Hour),
gocron.NewTask(
func() {
updateMetrics(&metrics, lastUpdatedMetric)
},
),
)
if err != nil {
log.Error("Failed to create periodic task")
return err
}
// Perform an initial run to populate the metrics
log.Info("Performing initial requests...")
updateMetrics(&metrics, lastUpdatedMetric)
log.Debug("Done")
http.Handle(
"/metrics", promhttp.HandlerFor(
registry,
promhttp.HandlerOpts{
EnableOpenMetrics: true,
},
),
)
addr := host + ":" + fmt.Sprint(port)
log.Infof("Handling requests at %s", addr)
return http.ListenAndServe(addr, nil)
}
func main() {
app := &cli.App{
Action: run,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "debug",
Value: false,
},
&cli.StringFlag{
Name: "host",
Usage: "Host to expose metrics on",
Value: "0.0.0.0",
},
&cli.IntFlag{
Name: "port",
Usage: "Port to expose metrics on",
Value: 8888,
},
&cli.StringSliceFlag{
Name: "domain",
Usage: "<domain>:<port>",
Required: true,
},
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}