2024-07-19 22:01:34 +02:00
|
|
|
package sslcert // import nocternity.net/gomonop/cmd/sslcert
|
2021-01-03 10:33:21 +01:00
|
|
|
|
|
|
|
import (
|
2021-02-19 17:33:13 +01:00
|
|
|
"bufio"
|
2021-01-03 10:33:21 +01:00
|
|
|
"crypto/tls"
|
|
|
|
"crypto/x509"
|
|
|
|
"fmt"
|
2021-02-19 16:53:34 +01:00
|
|
|
"net"
|
|
|
|
"net/textproto"
|
2021-02-19 10:53:07 +01:00
|
|
|
"os"
|
2024-07-19 22:01:34 +02:00
|
|
|
"strconv"
|
2021-01-03 10:33:21 +01:00
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
2021-02-19 10:53:07 +01:00
|
|
|
"github.com/karrick/golf"
|
2024-07-19 22:01:34 +02:00
|
|
|
|
|
|
|
"nocternity.net/gomonop/pkg/perfdata"
|
|
|
|
"nocternity.net/gomonop/pkg/plugin"
|
2024-07-20 10:01:05 +02:00
|
|
|
"nocternity.net/gomonop/pkg/results"
|
2024-07-20 18:56:51 +02:00
|
|
|
"nocternity.net/gomonop/pkg/status"
|
2021-01-03 10:33:21 +01:00
|
|
|
)
|
|
|
|
|
2021-02-19 16:53:34 +01:00
|
|
|
//--------------------------------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
// Interface that can be implemented to fetch TLS certificates.
|
|
|
|
type certGetter interface {
|
|
|
|
getCertificate(tlsConfig *tls.Config, address string) (*x509.Certificate, error)
|
|
|
|
}
|
|
|
|
|
2024-07-19 22:01:34 +02:00
|
|
|
// Full TLS certificate fetcher.
|
2021-02-19 16:53:34 +01:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2024-07-19 22:01:34 +02:00
|
|
|
// SMTP+STARTTLS certificate getter.
|
2021-02-19 16:53:34 +01:00
|
|
|
type smtpGetter struct{}
|
|
|
|
|
2024-07-19 22:01:34 +02:00
|
|
|
func (f smtpGetter) cmd(tcon *textproto.Conn, expectCode int, text string) error {
|
2021-02-19 16:53:34 +01:00
|
|
|
id, err := tcon.Cmd("%s", text)
|
|
|
|
if err != nil {
|
2024-07-19 22:01:34 +02:00
|
|
|
return err
|
2021-02-19 16:53:34 +01:00
|
|
|
}
|
|
|
|
tcon.StartResponse(id)
|
|
|
|
defer tcon.EndResponse(id)
|
2024-07-19 22:01:34 +02:00
|
|
|
_, _, err = tcon.ReadResponse(expectCode)
|
|
|
|
return err
|
2021-02-19 16:53:34 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
2024-07-19 22:01:34 +02:00
|
|
|
if err := f.cmd(text, 250, "HELO localhost"); err != nil {
|
2021-02-19 16:53:34 +01:00
|
|
|
return nil, err
|
|
|
|
}
|
2024-07-19 22:01:34 +02:00
|
|
|
if err := f.cmd(text, 220, "STARTTLS"); err != nil {
|
2021-02-19 16:53:34 +01:00
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
t := tls.Client(conn, tlsConfig)
|
|
|
|
if err := t.Handshake(); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
return t.ConnectionState().PeerCertificates[0], nil
|
|
|
|
}
|
|
|
|
|
2024-07-19 22:01:34 +02:00
|
|
|
// ManageSieve STARTTLS certificate getter.
|
2021-02-19 17:33:13 +01:00
|
|
|
type sieveGetter struct{}
|
|
|
|
|
2024-07-19 22:01:34 +02:00
|
|
|
type sieveError struct {
|
|
|
|
msg string
|
|
|
|
}
|
|
|
|
|
|
|
|
func (e sieveError) Error() string {
|
|
|
|
return "Sieve error: " + e.msg
|
|
|
|
}
|
|
|
|
|
2021-02-19 17:33:13 +01:00
|
|
|
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 ") {
|
2024-07-19 22:01:34 +02:00
|
|
|
return sieveError{msg: line[3:]}
|
2021-02-19 17:33:13 +01:00
|
|
|
}
|
|
|
|
if strings.HasPrefix(line, "BYE ") {
|
2024-07-19 22:01:34 +02:00
|
|
|
return sieveError{msg: line[4:]}
|
2021-02-19 17:33:13 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2024-07-19 22:01:34 +02:00
|
|
|
// Supported StartTLS protocols.
|
|
|
|
var certGetters = map[string]certGetter{
|
2021-02-19 17:33:13 +01:00
|
|
|
"": fullTLSGetter{},
|
|
|
|
"smtp": &smtpGetter{},
|
|
|
|
"sieve": &sieveGetter{},
|
2021-02-19 16:53:34 +01:00
|
|
|
}
|
|
|
|
|
2024-07-19 22:01:34 +02:00
|
|
|
// Get a string that represents supported StartTLS protocols.
|
2021-02-19 17:37:57 +01:00
|
|
|
func listSupportedGetters() string {
|
2024-07-19 22:01:34 +02:00
|
|
|
strBuilder := strings.Builder{}
|
2021-02-19 17:39:08 +01:00
|
|
|
for key := range certGetters {
|
2024-07-19 22:01:34 +02:00
|
|
|
if strBuilder.Len() != 0 {
|
|
|
|
strBuilder.WriteString(", ")
|
2021-02-19 17:37:57 +01:00
|
|
|
}
|
2024-07-19 22:01:34 +02:00
|
|
|
strBuilder.WriteString(key)
|
2021-02-19 17:37:57 +01:00
|
|
|
}
|
2024-07-19 22:01:34 +02:00
|
|
|
return strBuilder.String()
|
2021-02-19 17:37:57 +01:00
|
|
|
}
|
|
|
|
|
2021-02-19 16:53:34 +01:00
|
|
|
//--------------------------------------------------------------------------------------------------------
|
|
|
|
|
2021-02-19 13:41:50 +01:00
|
|
|
// Command line flags that have been parsed.
|
2021-02-19 10:53:07 +01:00
|
|
|
type programFlags struct {
|
2021-02-19 13:41:50 +01:00
|
|
|
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
|
2021-02-19 16:53:34 +01:00
|
|
|
startTLS string // Protocol to use before requesting a switch to TLS.
|
2021-02-19 10:53:07 +01:00
|
|
|
}
|
2021-02-19 10:38:18 +01:00
|
|
|
|
2021-02-19 13:41:50 +01:00
|
|
|
// Program data including configuration and runtime data.
|
2021-02-19 10:53:07 +01:00
|
|
|
type checkProgram struct {
|
2021-02-19 13:41:50 +01:00
|
|
|
programFlags // Flags from the command line
|
2024-07-20 10:01:05 +02:00
|
|
|
plugin *results.Results // Plugin output state
|
2021-02-19 13:41:50 +01:00
|
|
|
certificate *x509.Certificate // X.509 certificate from the server
|
2021-01-03 10:33:21 +01:00
|
|
|
}
|
|
|
|
|
2021-02-19 13:41:50 +01:00
|
|
|
// Parse command line arguments and store their values. If the -h flag is present,
|
|
|
|
// help will be displayed and the program will exit.
|
2021-02-19 10:53:07 +01:00
|
|
|
func (flags *programFlags) parseArguments() {
|
2021-02-19 13:21:06 +01:00
|
|
|
var (
|
|
|
|
names string
|
|
|
|
help bool
|
|
|
|
)
|
2021-02-19 10:53:07 +01:00
|
|
|
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.")
|
2021-02-19 13:21:06 +01:00
|
|
|
golf.StringVarP(&names, 'a', "additional-names", "",
|
|
|
|
"A comma-separated list of names that the certificate should also provide.")
|
2021-02-19 16:53:34 +01:00
|
|
|
golf.StringVarP(&flags.startTLS, 's', "start-tls", "",
|
2021-02-19 17:37:57 +01:00
|
|
|
fmt.Sprintf(
|
|
|
|
"Protocol to use before requesting a switch to TLS. "+
|
|
|
|
"Supported protocols: %s.",
|
|
|
|
listSupportedGetters()))
|
2021-02-19 10:53:07 +01:00
|
|
|
golf.Parse()
|
|
|
|
if help {
|
|
|
|
golf.Usage()
|
|
|
|
os.Exit(0)
|
|
|
|
}
|
2021-02-19 13:21:06 +01:00
|
|
|
if names == "" {
|
|
|
|
flags.extraNames = make([]string, 0)
|
|
|
|
} else {
|
|
|
|
flags.extraNames = strings.Split(names, ",")
|
|
|
|
}
|
2021-02-19 10:53:07 +01:00
|
|
|
}
|
|
|
|
|
2021-02-19 13:41:50 +01:00
|
|
|
// Initialise the monitoring check program.
|
2024-07-20 10:01:05 +02:00
|
|
|
func NewProgram() plugin.Plugin {
|
2021-02-19 10:53:07 +01:00
|
|
|
program := &checkProgram{
|
2024-07-20 10:01:05 +02:00
|
|
|
plugin: results.New("Certificate check"),
|
2021-02-19 10:38:18 +01:00
|
|
|
}
|
2021-02-19 10:53:07 +01:00
|
|
|
program.parseArguments()
|
|
|
|
return program
|
2021-01-03 10:33:21 +01:00
|
|
|
}
|
|
|
|
|
2024-07-20 10:01:05 +02:00
|
|
|
// Return the program's output value.
|
|
|
|
func (program *checkProgram) Results() *results.Results {
|
|
|
|
return program.plugin
|
2021-02-19 10:38:18 +01:00
|
|
|
}
|
|
|
|
|
2021-02-19 13:41:50 +01:00
|
|
|
// Check the values that were specified from the command line. Returns true
|
|
|
|
// if the arguments made sense.
|
2024-07-19 22:01:34 +02:00
|
|
|
func (program *checkProgram) CheckArguments() bool {
|
2021-02-19 10:38:18 +01:00
|
|
|
if program.hostname == "" {
|
2024-07-20 18:56:51 +02:00
|
|
|
program.plugin.SetState(status.StatusUnknown, "no hostname specified")
|
2021-01-03 10:33:21 +01:00
|
|
|
return false
|
|
|
|
}
|
2021-02-19 10:38:18 +01:00
|
|
|
if program.port < 1 || program.port > 65535 {
|
2024-07-20 18:56:51 +02:00
|
|
|
program.plugin.SetState(status.StatusUnknown, "invalid or missing port number")
|
2021-01-03 10:33:21 +01:00
|
|
|
return false
|
|
|
|
}
|
2021-02-19 10:38:18 +01:00
|
|
|
if program.warn != -1 && program.crit != -1 && program.warn <= program.crit {
|
2024-07-20 18:56:51 +02:00
|
|
|
program.plugin.SetState(status.StatusUnknown, "nonsensical thresholds")
|
2021-01-03 10:33:21 +01:00
|
|
|
return false
|
|
|
|
}
|
2021-02-19 16:53:34 +01:00
|
|
|
if _, ok := certGetters[program.startTLS]; !ok {
|
2024-07-19 22:01:34 +02:00
|
|
|
errstr := "unsupported StartTLS protocol " + program.startTLS
|
2024-07-20 18:56:51 +02:00
|
|
|
program.plugin.SetState(status.StatusUnknown, errstr)
|
2021-02-19 16:53:34 +01:00
|
|
|
return false
|
|
|
|
}
|
2021-02-19 10:38:18 +01:00
|
|
|
program.hostname = strings.ToLower(program.hostname)
|
2021-01-03 10:33:21 +01:00
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
2021-02-19 13:41:50 +01:00
|
|
|
// Connect to the remote host and obtain the certificate. Returns an error
|
|
|
|
// if connecting or performing the TLS handshake fail.
|
2021-02-19 10:38:18 +01:00
|
|
|
func (program *checkProgram) getCertificate() error {
|
|
|
|
tlsConfig := &tls.Config{
|
2024-07-19 22:01:34 +02:00
|
|
|
//nolint:gosec // The whole point is to read the certificate.
|
2021-02-19 10:38:18 +01:00
|
|
|
InsecureSkipVerify: true,
|
|
|
|
MinVersion: tls.VersionTLS10,
|
|
|
|
}
|
|
|
|
connString := fmt.Sprintf("%s:%d", program.hostname, program.port)
|
2021-02-19 16:53:34 +01:00
|
|
|
certificate, err := certGetters[program.startTLS].getCertificate(tlsConfig, connString)
|
|
|
|
program.certificate = certificate
|
|
|
|
return err
|
2021-02-19 10:38:18 +01:00
|
|
|
}
|
|
|
|
|
2021-02-19 16:53:34 +01:00
|
|
|
// Check that the CN of a certificate that doesn't contain a SAN actually
|
|
|
|
// matches the requested host name.
|
2021-02-19 13:21:06 +01:00
|
|
|
func (program *checkProgram) checkSANlessCertificate() bool {
|
|
|
|
if !program.ignoreCnOnly || len(program.extraNames) != 0 {
|
2024-07-20 18:56:51 +02:00
|
|
|
program.plugin.SetState(status.StatusWarning,
|
2021-02-19 13:21:06 +01:00
|
|
|
"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)) {
|
2024-07-20 18:56:51 +02:00
|
|
|
program.plugin.SetState(status.StatusCritical, "incorrect certificate CN")
|
2021-02-19 10:38:18 +01:00
|
|
|
return false
|
2021-01-03 10:33:21 +01:00
|
|
|
}
|
2021-02-19 10:38:18 +01:00
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
2021-02-19 13:41:50 +01:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
}
|
2024-07-19 22:01:34 +02:00
|
|
|
program.plugin.AddLine("missing DNS name " + name + " in certificate")
|
2021-02-19 13:41:50 +01:00
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
// Ensure the certificate matches the specified names. Returns false if it
|
|
|
|
// doesn't.
|
2021-02-19 13:21:06 +01:00
|
|
|
func (program *checkProgram) checkNames() bool {
|
|
|
|
if len(program.certificate.DNSNames) == 0 {
|
|
|
|
return program.checkSANlessCertificate()
|
|
|
|
}
|
2024-07-19 22:01:34 +02:00
|
|
|
certificateIsOk := program.checkHostName(program.hostname)
|
2021-02-19 13:21:06 +01:00
|
|
|
for _, name := range program.extraNames {
|
2024-07-19 22:01:34 +02:00
|
|
|
certificateIsOk = program.checkHostName(name) && certificateIsOk
|
2021-02-19 13:21:06 +01:00
|
|
|
}
|
2024-07-19 22:01:34 +02:00
|
|
|
if !certificateIsOk {
|
2024-07-20 18:56:51 +02:00
|
|
|
program.plugin.SetState(status.StatusCritical, "names missing from SAN domain names")
|
2021-02-19 13:21:06 +01:00
|
|
|
}
|
2024-07-19 22:01:34 +02:00
|
|
|
return certificateIsOk
|
2021-02-19 13:21:06 +01:00
|
|
|
}
|
|
|
|
|
2024-07-19 22:01:34 +02:00
|
|
|
// Check a certificate's time to expiry against the warning and critical
|
2021-02-19 13:41:50 +01:00
|
|
|
// thresholds, returning a status code and description based on these
|
|
|
|
// values.
|
2024-07-20 18:56:51 +02:00
|
|
|
func (program *checkProgram) checkCertificateExpiry(tlDays int) (status.Status, string) {
|
2021-02-19 15:58:40 +01:00
|
|
|
if tlDays <= 0 {
|
2024-07-20 18:56:51 +02:00
|
|
|
return status.StatusCritical, "certificate expired"
|
2021-02-19 15:58:40 +01:00
|
|
|
}
|
2024-07-19 22:01:34 +02:00
|
|
|
|
2021-02-19 10:38:18 +01:00
|
|
|
var limitStr string
|
2024-07-20 18:56:51 +02:00
|
|
|
var state status.Status
|
2024-07-19 22:01:34 +02:00
|
|
|
|
|
|
|
switch {
|
|
|
|
case program.crit > 0 && tlDays <= program.crit:
|
2021-02-19 10:38:18 +01:00
|
|
|
limitStr = fmt.Sprintf(" (<= %d)", program.crit)
|
2024-07-20 18:56:51 +02:00
|
|
|
state = status.StatusCritical
|
2024-07-19 22:01:34 +02:00
|
|
|
|
|
|
|
case program.warn > 0 && tlDays <= program.warn:
|
2021-02-19 10:38:18 +01:00
|
|
|
limitStr = fmt.Sprintf(" (<= %d)", program.warn)
|
2024-07-20 18:56:51 +02:00
|
|
|
state = status.StatusWarning
|
2024-07-19 22:01:34 +02:00
|
|
|
|
|
|
|
default:
|
2021-02-19 10:38:18 +01:00
|
|
|
limitStr = ""
|
2024-07-20 18:56:51 +02:00
|
|
|
state = status.StatusOK
|
2021-01-03 10:33:21 +01:00
|
|
|
}
|
2024-07-19 22:01:34 +02:00
|
|
|
|
2021-02-19 10:38:18 +01:00
|
|
|
statusString := fmt.Sprintf("certificate will expire in %d days%s",
|
|
|
|
tlDays, limitStr)
|
|
|
|
return state, statusString
|
|
|
|
}
|
2021-01-03 10:33:21 +01:00
|
|
|
|
2021-02-19 13:41:50 +01:00
|
|
|
// Set the plugin's performance data based on the time left before the
|
|
|
|
// certificate expires and the thresholds.
|
2021-02-19 10:38:18 +01:00
|
|
|
func (program *checkProgram) setPerfData(tlDays int) {
|
2024-07-19 22:01:34 +02:00
|
|
|
pdat := perfdata.New("validity", perfdata.UomNone, strconv.Itoa(tlDays))
|
2021-02-19 10:38:18 +01:00
|
|
|
if program.crit > 0 {
|
2024-07-20 10:01:05 +02:00
|
|
|
pdat.SetCrit(perfdata.RangeMax(strconv.Itoa(program.crit)))
|
2021-02-19 10:38:18 +01:00
|
|
|
}
|
|
|
|
if program.warn > 0 {
|
2024-07-20 10:01:05 +02:00
|
|
|
pdat.SetWarn(perfdata.RangeMax(strconv.Itoa(program.warn)))
|
2021-02-19 10:38:18 +01:00
|
|
|
}
|
|
|
|
program.plugin.AddPerfData(pdat)
|
|
|
|
}
|
|
|
|
|
2021-02-19 13:41:50 +01:00
|
|
|
// Run the check: fetch the certificate, check its names then check its time
|
|
|
|
// to expiry and update the plugin's performance data.
|
2024-07-19 22:01:34 +02:00
|
|
|
func (program *checkProgram) RunCheck() {
|
2021-02-19 10:38:18 +01:00
|
|
|
err := program.getCertificate()
|
|
|
|
if err != nil {
|
2024-07-20 18:56:51 +02:00
|
|
|
program.plugin.SetState(status.StatusUnknown, err.Error())
|
2021-02-19 13:21:06 +01:00
|
|
|
} else if program.checkNames() {
|
2024-07-19 22:01:34 +02:00
|
|
|
timeLeft := time.Until(program.certificate.NotAfter)
|
2021-02-19 10:38:18 +01:00
|
|
|
tlDays := int((timeLeft + 86399*time.Second) / (24 * time.Hour))
|
|
|
|
program.plugin.SetState(program.checkCertificateExpiry(tlDays))
|
|
|
|
program.setPerfData(tlDays)
|
2021-01-03 10:33:21 +01:00
|
|
|
}
|
2021-02-19 10:38:18 +01:00
|
|
|
}
|