diff --git a/backend/handlers/certCheckHandlers.go b/backend/handlers/certCheckHandlers.go index 45e2e34..b23484b 100644 --- a/backend/handlers/certCheckHandlers.go +++ b/backend/handlers/certCheckHandlers.go @@ -27,7 +27,7 @@ func (h Handlers) CheckStatus(w http.ResponseWriter, r *http.Request) { log.Println("Received message for URL: " + params.Url) - result, err := shared.CheckCertStatus(params) + result, err := shared.CheckCertStatus(params, h.ExpirationWarningDays) if err != nil { h.JSON(w, http.StatusBadRequest, &models.ErrorResult{Errors: []string{err.Error()}}) diff --git a/backend/handlers/handlers.go b/backend/handlers/handlers.go index 147ed43..7807e58 100644 --- a/backend/handlers/handlers.go +++ b/backend/handlers/handlers.go @@ -10,7 +10,8 @@ import ( ) type Handlers struct { - SiteList []string + SiteList []string + ExpirationWarningDays int } func (h Handlers) JSON(w http.ResponseWriter, statusCode int, data interface{}) { diff --git a/backend/jobs/CheckCertJob.go b/backend/jobs/CheckCertJob.go new file mode 100644 index 0000000..0a89653 --- /dev/null +++ b/backend/jobs/CheckCertJob.go @@ -0,0 +1,146 @@ +package jobs + +import ( + "fmt" + "log" + "time" + + "github.com/adhocore/gronx" + "github.com/jlucaspains/sharp-cert-manager/models" + "github.com/jlucaspains/sharp-cert-manager/shared" +) + +type Notifier interface { + Notify(result []CertCheckNotification) error +} + +type CheckCertJob struct { + cron string + ticker *time.Ticker + gron gronx.Gronx + siteList []string + running bool + notifier Notifier + level Level + warningDays int +} + +type Level int + +const ( + Info Level = iota + Warning + Error +) + +var levels = map[string]Level{ + "Info": Info, + "Warning": Warning, + "Error": Error, +} + +type CertCheckNotification struct { + Hostname string + IsValid bool + Messages []string + ExpirationWarning bool +} + +func (c *CheckCertJob) Init(schedule string, level string, warningDays int, siteList []string, notifier Notifier) error { + c.gron = gronx.New() + + if schedule == "" || !c.gron.IsValid(schedule) { + log.Printf("A valid cron schedule is required in the format e.g.: * * * * *") + return fmt.Errorf("a valid cron schedule is required") + } + + if notifier == nil { + log.Printf("A valid notifier is required") + return fmt.Errorf("a valid notifier is required") + } + + levelValue, ok := levels[level] + if !ok { + levelValue = Warning + } + + if warningDays <= 0 { + warningDays = 30 + } + + c.cron = schedule + c.siteList = siteList + c.ticker = time.NewTicker(time.Minute) + c.notifier = notifier + c.level = levelValue + c.warningDays = warningDays + + return nil +} + +func (c *CheckCertJob) Start() { + c.running = true + go func() { + for range c.ticker.C { + c.tryExecute() + } + }() +} + +func (c *CheckCertJob) Stop() { + c.running = false + c.ticker.Stop() +} + +func (c *CheckCertJob) tryExecute() { + due, _ := c.gron.IsDue(c.cron, time.Now().Truncate(time.Minute)) + + log.Printf("tryExecute job, isDue: %t", due) + + if due { + c.execute() + } +} + +func (c *CheckCertJob) execute() { + result := []CertCheckNotification{} + for _, url := range c.siteList { + params := models.CertCheckParams{Url: url} + checkStatus, err := shared.CheckCertStatus(params, c.warningDays) + + if err != nil { + log.Printf("Error checking cert status: %s", err) + continue + } + + log.Printf("Cert status for %s: %t", url, checkStatus.IsValid) + + item := c.getNotificationModel(checkStatus) + if c.shouldNotify(item) { + result = append(result, item) + } + } + + c.notifier.Notify(result) +} + +func (c *CheckCertJob) shouldNotify(model CertCheckNotification) bool { + return c.level == Info || !model.IsValid || (c.level == Warning && model.ExpirationWarning) +} + +func (c *CheckCertJob) getNotificationModel(certificate models.CertCheckResult) CertCheckNotification { + result := CertCheckNotification{ + Hostname: certificate.Hostname, + IsValid: certificate.IsValid, + ExpirationWarning: certificate.ExpirationWarning, + Messages: certificate.ValidationIssues, + } + + if certificate.IsValid && result.ExpirationWarning { + // Calculate CertEndDate days from today + days := int(time.Until(certificate.CertEndDate).Hours() / 24) + result.Messages = append(result.Messages, fmt.Sprintf("Certificate expires in %d days", days)) + } + + return result +} diff --git a/backend/jobs/checkCerts_test.go b/backend/jobs/CheckCertJob_test.go similarity index 50% rename from backend/jobs/checkCerts_test.go rename to backend/jobs/CheckCertJob_test.go index b5699fd..08bad4f 100644 --- a/backend/jobs/checkCerts_test.go +++ b/backend/jobs/CheckCertJob_test.go @@ -3,7 +3,6 @@ package jobs import ( "testing" - "github.com/jlucaspains/sharp-cert-manager/models" "github.com/stretchr/testify/assert" ) @@ -11,7 +10,7 @@ type mockNotifier struct { executed bool } -func (m *mockNotifier) Notify(result []models.CertCheckResult) error { +func (m *mockNotifier) Notify(result []CertCheckNotification) error { m.executed = true return nil } @@ -19,7 +18,7 @@ func (m *mockNotifier) Notify(result []models.CertCheckResult) error { func TestJobInit(t *testing.T) { checkCertJob := &CheckCertJob{} - checkCertJob.Init("* * * * *", []string{"https://blog.lpains.net"}, &mockNotifier{}) + checkCertJob.Init("* * * * *", "", 1, []string{"https://blog.lpains.net"}, &mockNotifier{}) assert.Equal(t, "* * * * *", checkCertJob.cron) assert.Equal(t, "https://blog.lpains.net", checkCertJob.siteList[0]) @@ -27,10 +26,22 @@ func TestJobInit(t *testing.T) { checkCertJob.ticker.Stop() } +func TestJobInitDefaultWarningDays(t *testing.T) { + checkCertJob := &CheckCertJob{} + + checkCertJob.Init("* * * * *", "", 0, []string{"https://blog.lpains.net"}, &mockNotifier{}) + + assert.Equal(t, "* * * * *", checkCertJob.cron) + assert.Equal(t, "https://blog.lpains.net", checkCertJob.siteList[0]) + assert.Equal(t, 30, checkCertJob.warningDays) + + checkCertJob.ticker.Stop() +} + func TestJobInitBadCron(t *testing.T) { checkCertJob := &CheckCertJob{} - err := checkCertJob.Init("* * * *", []string{"https://blog.lpains.net"}, &mockNotifier{}) + err := checkCertJob.Init("* * * *", "", 0, []string{"https://blog.lpains.net"}, &mockNotifier{}) assert.Equal(t, "a valid cron schedule is required", err.Error()) } @@ -38,7 +49,7 @@ func TestJobInitBadCron(t *testing.T) { func TestJobInitBadNotifier(t *testing.T) { checkCertJob := &CheckCertJob{} - err := checkCertJob.Init("* * * * *", []string{"https://blog.lpains.net"}, nil) + err := checkCertJob.Init("* * * * *", "", 0, []string{"https://blog.lpains.net"}, nil) assert.Equal(t, "a valid notifier is required", err.Error()) } @@ -46,7 +57,7 @@ func TestJobInitBadNotifier(t *testing.T) { func TestJobStartStop(t *testing.T) { checkCertJob := &CheckCertJob{} - err := checkCertJob.Init("* * * * *", []string{"https://blog.lpains.net"}, &mockNotifier{}) + err := checkCertJob.Init("* * * * *", "", 0, []string{"https://blog.lpains.net"}, &mockNotifier{}) assert.Nil(t, err) checkCertJob.Start() assert.True(t, checkCertJob.running) @@ -57,7 +68,7 @@ func TestJobStartStop(t *testing.T) { func TestTryExecuteNotDue(t *testing.T) { checkCertJob := &CheckCertJob{} notifier := &mockNotifier{} - checkCertJob.Init("0 0 1 1 1", []string{"https://blog.lpains.net"}, &mockNotifier{}) + checkCertJob.Init("0 0 1 1 1", "", 0, []string{"https://blog.lpains.net"}, &mockNotifier{}) checkCertJob.notifier = notifier checkCertJob.tryExecute() @@ -67,7 +78,17 @@ func TestTryExecuteNotDue(t *testing.T) { func TestTryExecuteDue(t *testing.T) { checkCertJob := &CheckCertJob{} notifier := &mockNotifier{} - checkCertJob.Init("* * * * *", []string{"https://blog.lpains.net"}, &mockNotifier{}) + checkCertJob.Init("* * * * *", "", 0, []string{"https://blog.lpains.net"}, &mockNotifier{}) + checkCertJob.notifier = notifier + checkCertJob.tryExecute() + + assert.True(t, notifier.executed) +} + +func TestTryExecuteDueWarning(t *testing.T) { + checkCertJob := &CheckCertJob{} + notifier := &mockNotifier{} + checkCertJob.Init("* * * * *", "", 10000, []string{"https://blog.lpains.net"}, &mockNotifier{}) checkCertJob.notifier = notifier checkCertJob.tryExecute() diff --git a/backend/jobs/checkCerts.go b/backend/jobs/checkCerts.go deleted file mode 100644 index 1159b90..0000000 --- a/backend/jobs/checkCerts.go +++ /dev/null @@ -1,88 +0,0 @@ -package jobs - -import ( - "fmt" - "log" - "time" - - "github.com/adhocore/gronx" - "github.com/jlucaspains/sharp-cert-manager/models" - "github.com/jlucaspains/sharp-cert-manager/shared" -) - -type Notifier interface { - Notify(result []models.CertCheckResult) error -} - -type CheckCertJob struct { - cron string - ticker *time.Ticker - gron gronx.Gronx - siteList []string - running bool - notifier Notifier -} - -func (c *CheckCertJob) Init(schedule string, siteList []string, notifier Notifier) error { - c.gron = gronx.New() - - if schedule == "" || !c.gron.IsValid(schedule) { - log.Printf("A valid cron schedule is required in the format e.g.: * * * * *") - return fmt.Errorf("a valid cron schedule is required") - } - - if notifier == nil { - log.Printf("A valid notifier is required") - return fmt.Errorf("a valid notifier is required") - } - - c.cron = schedule - c.siteList = siteList - c.ticker = time.NewTicker(time.Minute) - c.notifier = notifier - - return nil -} - -func (c *CheckCertJob) Start() { - c.running = true - go func() { - for range c.ticker.C { - c.tryExecute() - } - }() -} - -func (c *CheckCertJob) Stop() { - c.running = false - c.ticker.Stop() -} - -func (c *CheckCertJob) tryExecute() { - due, _ := c.gron.IsDue(c.cron, time.Now().Truncate(time.Minute)) - - log.Printf("tryExecute job, isDue: %t", due) - - if due { - c.execute() - } -} - -func (c *CheckCertJob) execute() { - result := []models.CertCheckResult{} - for _, url := range c.siteList { - params := models.CertCheckParams{Url: url} - checkStatus, err := shared.CheckCertStatus(params) - - if err != nil { - log.Printf("Error checking cert status: %s", err) - continue - } - - log.Printf("Cert status for %s: %t", url, checkStatus.IsValid) - - result = append(result, checkStatus) - } - - c.notifier.Notify(result) -} diff --git a/backend/jobs/teamsNotifier.go b/backend/jobs/teamsNotifier.go index 16beec0..5f858cb 100644 --- a/backend/jobs/teamsNotifier.go +++ b/backend/jobs/teamsNotifier.go @@ -7,8 +7,6 @@ import ( "net/http" "text/template" "time" - - "github.com/jlucaspains/sharp-cert-manager/models" ) type TeamsNotifier struct { @@ -24,7 +22,7 @@ type TeamsNotificationCard struct { Title string Description string NotificationUrl string - Items []models.CertCheckResult + Items []CertCheckNotification } const messageTemplate = `{ @@ -71,7 +69,7 @@ const messageTemplate = `{ "items": [ { "type": "TextBlock", - "text": "{{if $item.IsValid}}✔️{{else}}❌{{end}}{{$item.Hostname}}" + "text": "{{if not $item.IsValid}}❌{{else if $item.ExpirationWarning}}⚠️{{else}}✔️{{end}}{{$item.Hostname}}" } ] }, @@ -80,7 +78,7 @@ const messageTemplate = `{ "items": [ { "type": "TextBlock", - "text": "{{ range $index, $element := $item.ValidationIssues}}{{if $index}}, {{end}}{{$element}}{{end}}", + "text": "{{ range $index, $element := $item.Messages}}{{if $index}}, {{end}}{{$element}}{{end}}", "wrap": true } ] @@ -105,7 +103,7 @@ const messageTemplate = `{ func (m *TeamsNotifier) Init(webhookUrl string, notificationTitle string, notificationBody string, notificationUrl string) { if notificationTitle == "" { - notificationTitle = "Cert Manager Check Summary" + notificationTitle = "Sharp Cert Manager Summary" } if notificationBody == "" { @@ -118,7 +116,7 @@ func (m *TeamsNotifier) Init(webhookUrl string, notificationTitle string, notifi m.WebhookUrl = webhookUrl } -func (m *TeamsNotifier) Notify(result []models.CertCheckResult) error { +func (m *TeamsNotifier) Notify(result []CertCheckNotification) error { client := m.getClient() parsedTemplate := m.getTemplate() card := TeamsNotificationCard{ diff --git a/backend/jobs/teamsNotifier_test.go b/backend/jobs/teamsNotifier_test.go index 960f758..3675941 100644 --- a/backend/jobs/teamsNotifier_test.go +++ b/backend/jobs/teamsNotifier_test.go @@ -6,7 +6,6 @@ import ( "net/http/httptest" "testing" - "github.com/jlucaspains/sharp-cert-manager/models" "github.com/stretchr/testify/assert" ) @@ -27,7 +26,7 @@ func TestTeamsNotifierExplicitInit(t *testing.T) { teamsNotifier := &TeamsNotifier{} teamsNotifier.Init(ts.URL, "title", "body", "url") - err := teamsNotifier.Notify([]models.CertCheckResult{}) + err := teamsNotifier.Notify([]CertCheckNotification{}) assert.Nil(t, err) assert.Equal(t, "{\n\t\"type\": \"message\",\n\t\"attachments\": [{\n\t\t\"contentType\": \"application/vnd.microsoft.card.adaptive\",\n\t\t\"content\": {\n\t\t\t\"type\": \"AdaptiveCard\",\n\t\t\t\"version\": \"1.5\",\n\t\t\t\"$schema\": \"http://adaptivecards.io/schemas/adaptive-card.json\",\n\t\t\t\"body\": [\n\t\t\t\t{\n\t\t\t\t\t\"type\": \"TextBlock\",\n\t\t\t\t\t\"text\": \"title\",\n\t\t\t\t\t\"size\": \"large\",\n\t\t\t\t\t\"weight\": \"bolder\",\n\t\t\t\t\t\"wrap\": true\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"type\": \"TextBlock\",\n\t\t\t\t\t\"text\": \"body\",\n\t\t\t\t\t\"isSubtle\": true,\n\t\t\t\t\t\"wrap\": true\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"type\": \"Table\",\n\t\t\t\t\t\"columns\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"width\": 2\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"width\": 4\n\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"rows\": [\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"actions\": [\n\t\t\t\t{\n\t\t\t\t\t\"type\": \"Action.OpenUrl\",\n\t\t\t\t\t\"title\": \"View Details\",\n\t\t\t\t\t\"url\": \"url\"\n\t\t\t\t}\n\t\t\t]\n\t\t}\n\t}]\n}", result) } @@ -49,9 +48,9 @@ func TestTeamsNotifierImplicitInit(t *testing.T) { teamsNotifier := &TeamsNotifier{} teamsNotifier.Init(ts.URL, "", "The following certificates were checked on today", "") - err := teamsNotifier.Notify([]models.CertCheckResult{}) + err := teamsNotifier.Notify([]CertCheckNotification{}) assert.Nil(t, err) - assert.Equal(t, "{\n\t\"type\": \"message\",\n\t\"attachments\": [{\n\t\t\"contentType\": \"application/vnd.microsoft.card.adaptive\",\n\t\t\"content\": {\n\t\t\t\"type\": \"AdaptiveCard\",\n\t\t\t\"version\": \"1.5\",\n\t\t\t\"$schema\": \"http://adaptivecards.io/schemas/adaptive-card.json\",\n\t\t\t\"body\": [\n\t\t\t\t{\n\t\t\t\t\t\"type\": \"TextBlock\",\n\t\t\t\t\t\"text\": \"Cert Manager Check Summary\",\n\t\t\t\t\t\"size\": \"large\",\n\t\t\t\t\t\"weight\": \"bolder\",\n\t\t\t\t\t\"wrap\": true\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"type\": \"TextBlock\",\n\t\t\t\t\t\"text\": \"The following certificates were checked on today\",\n\t\t\t\t\t\"isSubtle\": true,\n\t\t\t\t\t\"wrap\": true\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"type\": \"Table\",\n\t\t\t\t\t\"columns\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"width\": 2\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"width\": 4\n\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"rows\": [\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t]\n\t\t}\n\t}]\n}", result) + assert.Equal(t, "{\n\t\"type\": \"message\",\n\t\"attachments\": [{\n\t\t\"contentType\": \"application/vnd.microsoft.card.adaptive\",\n\t\t\"content\": {\n\t\t\t\"type\": \"AdaptiveCard\",\n\t\t\t\"version\": \"1.5\",\n\t\t\t\"$schema\": \"http://adaptivecards.io/schemas/adaptive-card.json\",\n\t\t\t\"body\": [\n\t\t\t\t{\n\t\t\t\t\t\"type\": \"TextBlock\",\n\t\t\t\t\t\"text\": \"Sharp Cert Manager Summary\",\n\t\t\t\t\t\"size\": \"large\",\n\t\t\t\t\t\"weight\": \"bolder\",\n\t\t\t\t\t\"wrap\": true\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"type\": \"TextBlock\",\n\t\t\t\t\t\"text\": \"The following certificates were checked on today\",\n\t\t\t\t\t\"isSubtle\": true,\n\t\t\t\t\t\"wrap\": true\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"type\": \"Table\",\n\t\t\t\t\t\"columns\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"width\": 2\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"width\": 4\n\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"rows\": [\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t]\n\t\t}\n\t}]\n}", result) } func TestTeamsNotifierWithData(t *testing.T) { @@ -71,11 +70,11 @@ func TestTeamsNotifierWithData(t *testing.T) { teamsNotifier := &TeamsNotifier{} teamsNotifier.Init(ts.URL, "", "The following certificates were checked on today", "") - err := teamsNotifier.Notify([]models.CertCheckResult{ + err := teamsNotifier.Notify([]CertCheckNotification{ {Hostname: "host1", IsValid: true}, }) assert.Nil(t, err) - assert.Equal(t, "{\n\t\"type\": \"message\",\n\t\"attachments\": [{\n\t\t\"contentType\": \"application/vnd.microsoft.card.adaptive\",\n\t\t\"content\": {\n\t\t\t\"type\": \"AdaptiveCard\",\n\t\t\t\"version\": \"1.5\",\n\t\t\t\"$schema\": \"http://adaptivecards.io/schemas/adaptive-card.json\",\n\t\t\t\"body\": [\n\t\t\t\t{\n\t\t\t\t\t\"type\": \"TextBlock\",\n\t\t\t\t\t\"text\": \"Cert Manager Check Summary\",\n\t\t\t\t\t\"size\": \"large\",\n\t\t\t\t\t\"weight\": \"bolder\",\n\t\t\t\t\t\"wrap\": true\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"type\": \"TextBlock\",\n\t\t\t\t\t\"text\": \"The following certificates were checked on today\",\n\t\t\t\t\t\"isSubtle\": true,\n\t\t\t\t\t\"wrap\": true\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"type\": \"Table\",\n\t\t\t\t\t\"columns\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"width\": 2\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"width\": 4\n\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"rows\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"type\": \"TableRow\",\n\t\t\t\t\t\t\t\"cells\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"type\": \"TableCell\",\n\t\t\t\t\t\t\t\t\t\"items\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"type\": \"TextBlock\",\n\t\t\t\t\t\t\t\t\t\t\"text\": \"✔️host1\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"type\": \"TableCell\",\n\t\t\t\t\t\t\t\t\t\"items\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"type\": \"TextBlock\",\n\t\t\t\t\t\t\t\t\t\t\"text\": \"\",\n\t\t\t\t\t\t\t\t\t\t\"wrap\": true\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t]\n\t\t}\n\t}]\n}", result) + assert.Equal(t, "{\n\t\"type\": \"message\",\n\t\"attachments\": [{\n\t\t\"contentType\": \"application/vnd.microsoft.card.adaptive\",\n\t\t\"content\": {\n\t\t\t\"type\": \"AdaptiveCard\",\n\t\t\t\"version\": \"1.5\",\n\t\t\t\"$schema\": \"http://adaptivecards.io/schemas/adaptive-card.json\",\n\t\t\t\"body\": [\n\t\t\t\t{\n\t\t\t\t\t\"type\": \"TextBlock\",\n\t\t\t\t\t\"text\": \"Sharp Cert Manager Summary\",\n\t\t\t\t\t\"size\": \"large\",\n\t\t\t\t\t\"weight\": \"bolder\",\n\t\t\t\t\t\"wrap\": true\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"type\": \"TextBlock\",\n\t\t\t\t\t\"text\": \"The following certificates were checked on today\",\n\t\t\t\t\t\"isSubtle\": true,\n\t\t\t\t\t\"wrap\": true\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"type\": \"Table\",\n\t\t\t\t\t\"columns\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"width\": 2\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"width\": 4\n\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"rows\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"type\": \"TableRow\",\n\t\t\t\t\t\t\t\"cells\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"type\": \"TableCell\",\n\t\t\t\t\t\t\t\t\t\"items\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"type\": \"TextBlock\",\n\t\t\t\t\t\t\t\t\t\t\"text\": \"✔️host1\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"type\": \"TableCell\",\n\t\t\t\t\t\t\t\t\t\"items\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"type\": \"TextBlock\",\n\t\t\t\t\t\t\t\t\t\t\"text\": \"\",\n\t\t\t\t\t\t\t\t\t\t\"wrap\": true\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t]\n\t\t}\n\t}]\n}", result) } func TestTeamsNotifierBadResponseCode(t *testing.T) { @@ -90,6 +89,6 @@ func TestTeamsNotifierBadResponseCode(t *testing.T) { teamsNotifier := &TeamsNotifier{} teamsNotifier.Init(ts.URL, "", "", "") - err := teamsNotifier.Notify([]models.CertCheckResult{}) + err := teamsNotifier.Notify([]CertCheckNotification{}) assert.Equal(t, "error sending notification to Teams", err.Error()) } diff --git a/backend/main.go b/backend/main.go index d46ff27..1ecaee9 100644 --- a/backend/main.go +++ b/backend/main.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "os/signal" + "strconv" "syscall" muxHandlers "github.com/gorilla/handlers" @@ -48,7 +49,9 @@ func startJobs(siteList []string) { if ok { log.Printf("Starting job engine with cron: %s", schedule) - err := checkCertJob.Init(schedule, siteList, getJobNotifier()) + level, _ := os.LookupEnv("CHECK_CERT_JOB_NOTIFICATION_LEVEL") + warningDays := getCertExpirationWarningDays() + err := checkCertJob.Init(schedule, level, warningDays, siteList, getJobNotifier()) if err == nil { checkCertJob.Start() log.Print("Job engine started") @@ -60,6 +63,17 @@ func startJobs(siteList []string) { } } +func getCertExpirationWarningDays() int { + warningDaysConfig, _ := os.LookupEnv("CERT_WARNING_VALIDITY_DAYS") + warningDays, _ := strconv.Atoi(warningDaysConfig) + + if warningDays > 0 { + return warningDays + } + + return 30 +} + func stopJobs() { checkCertJob.Stop() } @@ -67,6 +81,7 @@ func stopJobs() { func startWebServer(siteList []string) { handlers := &handlers.Handlers{} handlers.SiteList = siteList + handlers.ExpirationWarningDays = getCertExpirationWarningDays() router := mux.NewRouter() router.HandleFunc("/api/check-url", handlers.CheckStatus).Methods("GET") diff --git a/backend/models/certCheckResult.go b/backend/models/certCheckResult.go index ed77fde..1e44cfe 100644 --- a/backend/models/certCheckResult.go +++ b/backend/models/certCheckResult.go @@ -9,18 +9,19 @@ type OtherCert struct { } type CertCheckResult struct { - Hostname string `json:"hostname"` - Issuer string `json:"issuer"` - Signature string `json:"signature"` - CertStartDate time.Time `json:"certStartDate"` - CertEndDate time.Time `json:"certEndDate"` - CertDnsNames []string `json:"certDnsNames"` - IsValid bool `json:"isValid"` - TLSVersion uint16 `json:"tlsVersion"` - IsCA bool `json:"isCA"` - CommonName string `json:"commonName"` - OtherCerts []OtherCert `json:"otherCerts"` - ValidationIssues []string `json:"validationIssues"` + Hostname string `json:"hostname"` + Issuer string `json:"issuer"` + Signature string `json:"signature"` + CertStartDate time.Time `json:"certStartDate"` + CertEndDate time.Time `json:"certEndDate"` + CertDnsNames []string `json:"certDnsNames"` + IsValid bool `json:"isValid"` + TLSVersion uint16 `json:"tlsVersion"` + IsCA bool `json:"isCA"` + CommonName string `json:"commonName"` + OtherCerts []OtherCert `json:"otherCerts"` + ValidationIssues []string `json:"validationIssues"` + ExpirationWarning bool `json:"expirationWarning"` } type CheckListResult struct { diff --git a/backend/shared/certService.go b/backend/shared/certService.go index 684ae92..3c4d2c1 100644 --- a/backend/shared/certService.go +++ b/backend/shared/certService.go @@ -27,7 +27,7 @@ func GetConfigSites() []string { return siteList } -func CheckCertStatus(params models.CertCheckParams) (result models.CertCheckResult, err error) { +func CheckCertStatus(params models.CertCheckParams, expirationWarningDays int) (result models.CertCheckResult, err error) { if params.Url == "" { err = errors.New("url is required") return @@ -59,18 +59,19 @@ func CheckCertStatus(params models.CertCheckParams) (result models.CertCheckResu isValid, errors := validate(resp.TLS.PeerCertificates[0], hostName) result = models.CertCheckResult{ - Hostname: hostName, - Issuer: resp.TLS.PeerCertificates[0].Issuer.CommonName, - Signature: resp.TLS.PeerCertificates[0].SignatureAlgorithm.String(), - CertStartDate: certStartDate, - CertEndDate: certEndDate, - CertDnsNames: resp.TLS.PeerCertificates[0].DNSNames, - TLSVersion: resp.TLS.Version, - IsCA: resp.TLS.PeerCertificates[0].IsCA, - CommonName: resp.TLS.PeerCertificates[0].Subject.CommonName, - IsValid: isValid, - OtherCerts: getOtherCerts(resp.TLS.PeerCertificates[1:]), - ValidationIssues: errors, + Hostname: hostName, + Issuer: resp.TLS.PeerCertificates[0].Issuer.CommonName, + Signature: resp.TLS.PeerCertificates[0].SignatureAlgorithm.String(), + CertStartDate: certStartDate, + CertEndDate: certEndDate, + CertDnsNames: resp.TLS.PeerCertificates[0].DNSNames, + TLSVersion: resp.TLS.Version, + IsCA: resp.TLS.PeerCertificates[0].IsCA, + CommonName: resp.TLS.PeerCertificates[0].Subject.CommonName, + IsValid: isValid, + OtherCerts: getOtherCerts(resp.TLS.PeerCertificates[1:]), + ValidationIssues: errors, + ExpirationWarning: certEndDate.Before(time.Now().AddDate(0, 0, expirationWarningDays)), } return diff --git a/backend/shared/checkCerts_test.go b/backend/shared/checkCerts_test.go index 1de8ee4..ba10bf3 100644 --- a/backend/shared/checkCerts_test.go +++ b/backend/shared/checkCerts_test.go @@ -14,7 +14,7 @@ import ( func TestGetCheckStatus(t *testing.T) { url := "https://blog.lpains.net" - body, err := CheckCertStatus(models.CertCheckParams{Url: url}) + body, err := CheckCertStatus(models.CertCheckParams{Url: url}, 30) assert.Nil(t, err) assert.True(t, body.IsValid) @@ -24,9 +24,22 @@ func TestGetCheckStatus(t *testing.T) { assert.Contains(t, body.CertDnsNames, "blog.lpains.net") } +func TestGetCheckWarning(t *testing.T) { + url := "https://blog.lpains.net" + body, err := CheckCertStatus(models.CertCheckParams{Url: url}, 10000) + + assert.Nil(t, err) + assert.True(t, body.IsValid) + assert.LessOrEqual(t, body.CertStartDate, time.Now()) + assert.GreaterOrEqual(t, body.CertEndDate, time.Now()) + assert.Contains(t, body.Hostname, "blog.lpains.net") + assert.Contains(t, body.CertDnsNames, "blog.lpains.net") + assert.True(t, body.ExpirationWarning) +} + func TestGetCheckStatusNoUrl(t *testing.T) { url := "" - _, err := CheckCertStatus(models.CertCheckParams{Url: url}) + _, err := CheckCertStatus(models.CertCheckParams{Url: url}, 30) assert.NotNil(t, err) assert.Equal(t, "url is required", err.Error()) @@ -34,7 +47,7 @@ func TestGetCheckStatusNoUrl(t *testing.T) { func TestGetCheckStatusHttp(t *testing.T) { url := "http://blog.lpains.net" - body, err := CheckCertStatus(models.CertCheckParams{Url: url}) + body, err := CheckCertStatus(models.CertCheckParams{Url: url}, 30) assert.Nil(t, err) assert.False(t, body.IsValid) @@ -104,7 +117,7 @@ yjbTOuy8KoxNb15g3Ysesbw= defer ts.Close() url := ts.URL - body, err := CheckCertStatus(models.CertCheckParams{Url: url}) + body, err := CheckCertStatus(models.CertCheckParams{Url: url}, 30) assert.Nil(t, err) assert.False(t, body.IsValid) diff --git a/docs/TeamsDemo.jpg b/docs/TeamsDemo.jpg new file mode 100644 index 0000000..cbfbf88 Binary files /dev/null and b/docs/TeamsDemo.jpg differ diff --git a/docs/demo.jpeg b/docs/demo.jpeg index b87554a..8e74dde 100644 Binary files a/docs/demo.jpeg and b/docs/demo.jpeg differ diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 308beb4..8dfb271 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -96,10 +96,10 @@ item.certStartDate = new Date(data.certStartDate); item.certEndDate = new Date(data.certEndDate); item.validationIssues = data.validationIssues; + item.validityWarning = data.expirationWarning; const validity = item.certEndDate.getTime() - new Date().getTime(); item.validity = validity > 0 ? Math.floor(validity / (1000 * 3600 * 24)) : 0; - item.validityWarning = item.validity < 60; item.isLoaded = true; items = items; // for display refresh diff --git a/readme.md b/readme.md index fd956ed..3bb3a5b 100644 --- a/readme.md +++ b/readme.md @@ -1,17 +1,20 @@ # sharp-cert-manager This project aims to provide a simple tool to monitor certificate validity. It is composed of a golang backend API built using GO http server and a frontend build using [Svelte](https://svelte.dev/). -![Demo image](/docs/demo.jpeg) +![Demo frontend image](/docs/demo.jpeg) -At the moment, the app doesn't actively monitor the configured websites. Instead, they are only available in the frontend for review. +Additionally, the app can be configured to run a job at a given schedule. The job will check the configured websites and send a message to a Teams Webhook with a summary of the websites and their certificate validity. -## Getting started -The easiest way to get started is to run the Docker image published to [Docker Hub](https://hub.docker.com/repository/docker/jlucaspains/sharp-cert-manager/general). Replace the `SITE_1` parameter value with a website to monitor. To add other websites, just add parameters `SITE_n` where `n` is a integer. +![Demo teams message](/docs/TeamsDemo.jpg) -> Remember to install Docker before running the docker run command. +# Getting started +The easiest way to get started is to run the Docker image published to [Docker Hub](https://hub.docker.com/repository/docker/jlucaspains/sharp-cert-manager/general). Replace the `SITE_1` parameter value with a website to monitor. To add other websites, just add parameters `SITE_n` where `n` is an integer. ```bash -docker run -it -p 8000:8000 --env ENV=DEV --env SITE_1=https://expired.badssl.com/ jlucaspains/sharp-cert-manager +docker run -it -p 8000:8000 \ + --env ENV=DEV \ + --env SITE_1=https://expired.badssl.com/ \ + jlucaspains/sharp-cert-manager ``` ## Running locally @@ -49,6 +52,53 @@ Finally, run the app: go run main.go ``` +## Running in Azure +### Azure Container Instance +Create an ACI resource via Azure CLI. The following parameters may be adjusted +1. `--resource-group`: resource group to be used +2. `--name`: name of the ACI resource +3. `--dns-name-label`: DNS to expose the ACI under +4. `--environment-variables` + 1. `SITE_1..SITE_N`: monitored websites. + +```bash +az container create \ + --resource-group rg-sharpcertmanager-001 \ + --name aci-sharpcertmanager-001 \ + --image jlucaspains/sharp-cert-manager \ + --dns-name-label sharp-cert-manager \ + --ports 8000 \ + --environment-variables ENV=DEV SITE_1=https://expired.badssl.com/ +``` + +### Azure Container App +> While more expensive, an ACA is a better option for production environments as it provides a more robust and scalable environment. + +First, create an ACA environment using Azure CLI: + +```bash +az containerapp env create \ + --name ace-sharpcertmanager-001 \ + --resource-group rg-experiments-soutchcentralus-001 +``` + +Now, create the actual ACA. The following parameters may be adjusted: +1. `-g`: resource group to be used +2. `-n`: name of the app +4. `--env-vars` + 1. `SITE_1..SITE_N`: monitored websites. + +```bash +az containerapp create \ + -n aca-sharpcertmanager-001 \ + -g rg-experiments-soutchcentralus-001 \ + --image jlucaspains/sharp-cert-manager \ + --environment ace-sharpcertmanager-001 \ + --ingress external --target-port 8000 \ + --env-vars ENV=DEV SITE_1=https://expired.badssl.com/ \ + --query properties.configuration.ingress.fqdn +``` + ## Jobs and Teams Webhook The app can be configured to run a job at a given schedule. The job will check the configured websites and send a message to a Teams Webhook with a summary of the websites and their certificate validity. @@ -65,7 +115,33 @@ docker run -it -p 8000:8000 ` jlucaspains/sharp-cert-manager ``` +## All environment options +| Environment variable | Description | Default value | +|-----------------------------------|---------------------------------------------------------------------------------|-----------------------------------------------| +| ENV | Environment name. Used to configure the app to run in different environments. | | +| SITE_1..SITE_N | Websites to monitor. | | +| CHECK_CERT_JOB_SCHEDULE | Cron schedule to run the job that checks the certificates. | | +| TEAMS_WEBHOOK_URL | Teams Webhook URL to send the message to. | | +| TEAMS_MESSAGE_URL | URL to be used as Card action in Teams | | +| TEAMS_MESSAGE_TITLE | Teams message title | Sharp Cert Manager Summary | +| TEAMS_MESSAGE_BODY | Teams message body | The following certificates were checked on %s | +| WEB_HOST_PORT | host and port the web server will listen on | :8000 | +| TLS_CERT_FILE | Certificate used for TLS hosting | | +| TLS_CERT_KEY_FILE | Certificate key used for TLS hosting | | +| CERT_WARNING_VALIDITY_DAYS | Defines how many days from today a cert need to have to prevent a warning | 30 | +| CHECK_CERT_JOB_NOTIFICATION_LEVEL | Defines minimum notification level for jobs. Values are Info, Warning, or Error | Warning | + ## Security considerations This app is intended to run in private environments or at a minimum be behind a secure gateway with proper TLS and authentication to ensure it is not improperly used. The app will allow unsecured requests to the configured websites. It will perform a get and discard any data returned. All information used is derived from the connection and certificate negotiated between the http client and the web server being monitored. + +## Features +Below features are currentl being evaluated and/or built. If you have a suggestion, please create an issue. + +- [x] Display list of monitored certificates +- [x] Display certificate details +- [x] Monitor certificate in background +- [x] Teams WebHook integration +- [ ] Slack WebHook integration +- [ ] Monitoring \ No newline at end of file