Emmanuel BENOîT
78af496fe9
This PR refactors most of the internals to make them easier to test (and also because the names didn't make sense). It adds unit tests for all internal components. Reviewed-on: #2 Co-authored-by: Emmanuel BENOÎT <tseeker@nocternity.net> Co-committed-by: Emmanuel BENOÎT <tseeker@nocternity.net>
361 lines
11 KiB
Go
361 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"
|
|
)
|
|
|
|
//--------------------------------------------------------------------------------------------------------
|
|
|
|
// 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(results.StatusUnknown, "no hostname specified")
|
|
return false
|
|
}
|
|
if program.port < 1 || program.port > 65535 {
|
|
program.plugin.SetState(results.StatusUnknown, "invalid or missing port number")
|
|
return false
|
|
}
|
|
if program.warn != -1 && program.crit != -1 && program.warn <= program.crit {
|
|
program.plugin.SetState(results.StatusUnknown, "nonsensical thresholds")
|
|
return false
|
|
}
|
|
if _, ok := certGetters[program.startTLS]; !ok {
|
|
errstr := "unsupported StartTLS protocol " + program.startTLS
|
|
program.plugin.SetState(results.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(results.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(results.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(results.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) (results.Status, string) {
|
|
if tlDays <= 0 {
|
|
return results.StatusCritical, "certificate expired"
|
|
}
|
|
|
|
var limitStr string
|
|
var state results.Status
|
|
|
|
switch {
|
|
case program.crit > 0 && tlDays <= program.crit:
|
|
limitStr = fmt.Sprintf(" (<= %d)", program.crit)
|
|
state = results.StatusCritical
|
|
|
|
case program.warn > 0 && tlDays <= program.warn:
|
|
limitStr = fmt.Sprintf(" (<= %d)", program.warn)
|
|
state = results.StatusWarning
|
|
|
|
default:
|
|
limitStr = ""
|
|
state = results.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(results.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)
|
|
}
|
|
}
|