gomonop/cmd/sslcert/main.go
Emmanuel BENOîT c46c9d76d9
All checks were successful
Run tests and linters / test (push) Successful in 50s
Run tests and linters / build (push) Successful in 48s
Run tests and linters / lint (push) Successful in 1m27s
feat: add the check_output_matches plugin (#5)
This PR adds the `check_output_matches` plugin, which can be used to count regexp or substring matches from either text files or command outputs and determine the final status based on the amount of matches that were found.

Reviewed-on: #5
Co-authored-by: Emmanuel BENOÎT <tseeker@nocternity.net>
Co-committed-by: Emmanuel BENOÎT <tseeker@nocternity.net>
2024-07-20 22:57:10 +02:00

362 lines
11 KiB
Go

package sslcert // import nocternity.net/gomonop/cmd/sslcert
import (
"bufio"
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"net/textproto"
"os"
"strconv"
"strings"
"time"
"github.com/karrick/golf"
"nocternity.net/gomonop/pkg/perfdata"
"nocternity.net/gomonop/pkg/plugin"
"nocternity.net/gomonop/pkg/results"
"nocternity.net/gomonop/pkg/status"
)
//--------------------------------------------------------------------------------------------------------
// Interface that can be implemented to fetch TLS certificates.
type certGetter interface {
getCertificate(tlsConfig *tls.Config, address string) (*x509.Certificate, error)
}
// Full TLS certificate fetcher.
type fullTLSGetter struct{}
func (f fullTLSGetter) getCertificate(tlsConfig *tls.Config, address string) (*x509.Certificate, error) {
conn, err := tls.Dial("tcp", address, tlsConfig)
if err != nil {
return nil, err
}
defer conn.Close()
if err := conn.Handshake(); err != nil {
return nil, err
}
return conn.ConnectionState().PeerCertificates[0], nil
}
// SMTP+STARTTLS certificate getter.
type smtpGetter struct{}
func (f smtpGetter) cmd(tcon *textproto.Conn, expectCode int, text string) error {
id, err := tcon.Cmd("%s", text)
if err != nil {
return err
}
tcon.StartResponse(id)
defer tcon.EndResponse(id)
_, _, err = tcon.ReadResponse(expectCode)
return err
}
func (f smtpGetter) getCertificate(tlsConfig *tls.Config, address string) (*x509.Certificate, error) {
conn, err := net.Dial("tcp", address)
if err != nil {
return nil, err
}
text := textproto.NewConn(conn)
defer text.Close()
if _, _, err := text.ReadResponse(220); err != nil {
return nil, err
}
if err := f.cmd(text, 250, "HELO localhost"); err != nil {
return nil, err
}
if err := f.cmd(text, 220, "STARTTLS"); err != nil {
return nil, err
}
t := tls.Client(conn, tlsConfig)
if err := t.Handshake(); err != nil {
return nil, err
}
return t.ConnectionState().PeerCertificates[0], nil
}
// ManageSieve STARTTLS certificate getter.
type sieveGetter struct{}
type sieveError struct {
msg string
}
func (e sieveError) Error() string {
return "Sieve error: " + e.msg
}
func (f sieveGetter) waitOK(conn net.Conn) error {
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "OK") {
return nil
}
if strings.HasPrefix(line, "NO ") {
return sieveError{msg: line[3:]}
}
if strings.HasPrefix(line, "BYE ") {
return sieveError{msg: line[4:]}
}
}
return scanner.Err()
}
func (f sieveGetter) runCmd(conn net.Conn, cmd string) error {
if _, err := fmt.Fprintf(conn, "%s\r\n", cmd); err != nil {
return err
}
return f.waitOK(conn)
}
func (f sieveGetter) getCertificate(tlsConfig *tls.Config, address string) (*x509.Certificate, error) {
conn, err := net.Dial("tcp", address)
if err != nil {
return nil, err
}
defer conn.Close()
if err := f.waitOK(conn); err != nil {
return nil, err
}
if err := f.runCmd(conn, "STARTTLS"); err != nil {
return nil, err
}
t := tls.Client(conn, tlsConfig)
if err := t.Handshake(); err != nil {
return nil, err
}
return t.ConnectionState().PeerCertificates[0], nil
}
// Supported StartTLS protocols.
var certGetters = map[string]certGetter{
"": fullTLSGetter{},
"smtp": &smtpGetter{},
"sieve": &sieveGetter{},
}
// Get a string that represents supported StartTLS protocols.
func listSupportedGetters() string {
strBuilder := strings.Builder{}
for key := range certGetters {
if strBuilder.Len() != 0 {
strBuilder.WriteString(", ")
}
strBuilder.WriteString(key)
}
return strBuilder.String()
}
//--------------------------------------------------------------------------------------------------------
// Command line flags that have been parsed.
type programFlags struct {
hostname string // Main host name to connect to
port int // TCP port to connect to
warn int // Threshold for warning state (days)
crit int // Threshold for critical state (days)
ignoreCnOnly bool // Do not warn about SAN-less certificates
extraNames []string // Extra names the certificate should include
startTLS string // Protocol to use before requesting a switch to TLS.
}
// Program data including configuration and runtime data.
type checkProgram struct {
programFlags // Flags from the command line
plugin *results.Results // Plugin output state
certificate *x509.Certificate // X.509 certificate from the server
}
// Parse command line arguments and store their values. If the -h flag is present,
// help will be displayed and the program will exit.
func (flags *programFlags) parseArguments() {
var (
names string
help bool
)
golf.BoolVarP(&help, 'h', "help", false, "Display usage information")
golf.StringVarP(&flags.hostname, 'H', "hostname", "", "Host name to connect to.")
golf.IntVarP(&flags.port, 'P', "port", -1, "Port to connect to.")
golf.IntVarP(&flags.warn, 'W', "warning", -1,
"Validity threshold below which a warning state is issued, in days.")
golf.IntVarP(&flags.crit, 'C', "critical", -1,
"Validity threshold below which a critical state is issued, in days.")
golf.BoolVar(&flags.ignoreCnOnly, "ignore-cn-only", false,
"Do not issue warnings regarding certificates that do not use SANs at all.")
golf.StringVarP(&names, 'a', "additional-names", "",
"A comma-separated list of names that the certificate should also provide.")
golf.StringVarP(&flags.startTLS, 's', "start-tls", "",
fmt.Sprintf(
"Protocol to use before requesting a switch to TLS. "+
"Supported protocols: %s.",
listSupportedGetters()))
golf.Parse()
if help {
golf.Usage()
os.Exit(0)
}
if names == "" {
flags.extraNames = make([]string, 0)
} else {
flags.extraNames = strings.Split(names, ",")
}
}
// Initialise the monitoring check program.
func NewProgram() plugin.Plugin {
program := &checkProgram{
plugin: results.New("Certificate check"),
}
program.parseArguments()
return program
}
// Return the program's output value.
func (program *checkProgram) Results() *results.Results {
return program.plugin
}
// Check the values that were specified from the command line. Returns true
// if the arguments made sense.
func (program *checkProgram) CheckArguments() bool {
if program.hostname == "" {
program.plugin.SetState(status.StatusUnknown, "no hostname specified")
return false
}
if program.port < 1 || program.port > 65535 {
program.plugin.SetState(status.StatusUnknown, "invalid or missing port number")
return false
}
if program.warn != -1 && program.crit != -1 && program.warn <= program.crit {
program.plugin.SetState(status.StatusUnknown, "nonsensical thresholds")
return false
}
if _, ok := certGetters[program.startTLS]; !ok {
errstr := "unsupported StartTLS protocol " + program.startTLS
program.plugin.SetState(status.StatusUnknown, errstr)
return false
}
program.hostname = strings.ToLower(program.hostname)
return true
}
// Connect to the remote host and obtain the certificate. Returns an error
// if connecting or performing the TLS handshake fail.
func (program *checkProgram) getCertificate() error {
tlsConfig := &tls.Config{
//nolint:gosec // The whole point is to read the certificate.
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS10,
}
connString := fmt.Sprintf("%s:%d", program.hostname, program.port)
certificate, err := certGetters[program.startTLS].getCertificate(tlsConfig, connString)
program.certificate = certificate
return err
}
// Check that the CN of a certificate that doesn't contain a SAN actually
// matches the requested host name.
func (program *checkProgram) checkSANlessCertificate() bool {
if !program.ignoreCnOnly || len(program.extraNames) != 0 {
program.plugin.SetState(status.StatusWarning,
"certificate doesn't have SAN domain names")
return false
}
dn := strings.ToLower(program.certificate.Subject.String())
if !strings.HasPrefix(dn, fmt.Sprintf("cn=%s,", program.hostname)) {
program.plugin.SetState(status.StatusCritical, "incorrect certificate CN")
return false
}
return true
}
// Checks whether a name is listed in the certificate's DNS names. If the name
// cannot be found, a line will be added to the plugin output and false will
// be returned.
func (program *checkProgram) checkHostName(name string) bool {
for _, n := range program.certificate.DNSNames {
if strings.ToLower(n) == name {
return true
}
}
program.plugin.AddLine("missing DNS name " + name + " in certificate")
return false
}
// Ensure the certificate matches the specified names. Returns false if it
// doesn't.
func (program *checkProgram) checkNames() bool {
if len(program.certificate.DNSNames) == 0 {
return program.checkSANlessCertificate()
}
certificateIsOk := program.checkHostName(program.hostname)
for _, name := range program.extraNames {
certificateIsOk = program.checkHostName(name) && certificateIsOk
}
if !certificateIsOk {
program.plugin.SetState(status.StatusCritical, "names missing from SAN domain names")
}
return certificateIsOk
}
// Check a certificate's time to expiry against the warning and critical
// thresholds, returning a status code and description based on these
// values.
func (program *checkProgram) checkCertificateExpiry(tlDays int) (status.Status, string) {
if tlDays <= 0 {
return status.StatusCritical, "certificate expired"
}
var limitStr string
var state status.Status
switch {
case program.crit > 0 && tlDays <= program.crit:
limitStr = fmt.Sprintf(" (<= %d)", program.crit)
state = status.StatusCritical
case program.warn > 0 && tlDays <= program.warn:
limitStr = fmt.Sprintf(" (<= %d)", program.warn)
state = status.StatusWarning
default:
limitStr = ""
state = status.StatusOK
}
statusString := fmt.Sprintf("certificate will expire in %d days%s",
tlDays, limitStr)
return state, statusString
}
// Set the plugin's performance data based on the time left before the
// certificate expires and the thresholds.
func (program *checkProgram) setPerfData(tlDays int) {
pdat := perfdata.New("validity", perfdata.UomNone, strconv.Itoa(tlDays))
if program.crit > 0 {
pdat.SetCrit(perfdata.RangeMax(strconv.Itoa(program.crit)))
}
if program.warn > 0 {
pdat.SetWarn(perfdata.RangeMax(strconv.Itoa(program.warn)))
}
program.plugin.AddPerfData(pdat)
}
// Run the check: fetch the certificate, check its names then check its time
// to expiry and update the plugin's performance data.
func (program *checkProgram) RunCheck() {
err := program.getCertificate()
if err != nil {
program.plugin.SetState(status.StatusUnknown, err.Error())
} else if program.checkNames() {
timeLeft := time.Until(program.certificate.NotAfter)
tlDays := int((timeLeft + 86399*time.Second) / (24 * time.Hour))
program.plugin.SetState(program.checkCertificateExpiry(tlDays))
program.setPerfData(tlDays)
}
}