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 ::") } 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) }, ), gocron.WithStartAt(gocron.WithStartImmediately()), ) if err != nil { log.Error("Failed to create periodic task") return err } // Start the scheduler scheduler.Start() // Handle HTTP requests 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: ":", Required: true, }, }, } if err := app.Run(os.Args); err != nil { log.Fatal(err) } }