diff --git a/cmd/main.go b/cmd/main.go index 4b7292a..929f952 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -16,19 +16,24 @@ import ( ) func main() { - + // setup the data directory which is passed in via a program argument dataDirectory := flag.String("data-dir", "./data", "Directory to store configuration and cache files") flag.Parse() + // output the data directory to log and validate it log.Println("๐ Data directory set to", *dataDirectory) validateDirectory(*dataDirectory) + // setup the configuration directory configDirectory := configuration.ConfigDirectory{DataDir: *dataDirectory} log.Println("โคด๏ธ Loading configuration and cache files...") + // read the app configuration config := configDirectory.ReadAppConfig() + // configure the SMTP mailer var _mailer *service.MailerService = nil + // provide some sanity log messages, to confirm the alert and mailer settings if config.Config.Alerts.SendAlerts { if !config.Config.SMTP.Enabled { log.Println("โ Email notifications are disabled") @@ -42,24 +47,34 @@ func main() { } else { log.Println("๐ต Alerts are disabled") } + // for sanity, log the cache refresh interval parsed from the configuration log.Printf("๐ WHOIS cache refresh interval set to %s", configuration.WhoisRefreshInterval) + // read the domain configuration domains := configDirectory.ReadDomains() log.Printf("๐ Loaded %d domains from domain list", len(domains.DomainFile.Domains)) + // read the WHOIS cache whoisCache := configDirectory.ReadWhoisCache() log.Printf("๐ Found %d cached whois entries", len(whoisCache.FileContents.Entries)) + // initialize the web server app := echo.New() + // tell it we will provide custom error pages app.HTTPErrorHandler = handlers.CustomHTTPErrorHandler + // tell it about the static asset directories app.Static("/", "assets") + // use the logger middleware app.Use(middleware.Logger()) + // set up our routes handlers.SetupRoutes(app) handlers.SetupConfigRoutes(app, config) handlers.SetupDomainRoutes(app, domains) + + // if the mailer was configured, add the routes if _mailer != nil { handlers.SetupMailerRoutes(app, _mailer, config.Config.Alerts.Admin) } @@ -77,18 +92,102 @@ func main() { } whoisRefreshOnSchedule(whoisCache, domains, configuration.WhoisRefreshInterval) - log.Printf("๐ Refreshing WHOIS cache every %s", configuration.WhoisRefreshInterval) + log.Printf("๐ Scheduler running WHOIS expiration checks every %s", configuration.WhoisRefreshInterval) + }) + + // Connect scheduler for domain expiration checks. First delay is after 60 seconds, then every (configured amount) of hours + // Does not automatically update the interval if the config changes, so a server reset is required to change the interval + // This uses the WhoisRefreshInterval as the interval for the domain expiration checks + time.AfterFunc(60*time.Second, func() { + domainExpirationCheckOnSchedule(whoisCache, domains, _mailer, config.Config, configuration.WhoisRefreshInterval) + log.Printf("๐ Scheduler running domain expiration checks every %s", configuration.WhoisRefreshInterval) }) // Start server on configured port app.Logger.Fatal(app.Start(":" + fmt.Sprint(config.Config.App.Port))) } +// When called on schedule, check for domain expirations in the WHOIS cache and send mail +func domainExpirationCheckOnSchedule(whoisCache configuration.WhoisCacheStorage, domains configuration.DomainConfiguration, mailer *service.MailerService, appConfig configuration.ConfigurationFile, interval time.Duration) { + if mailer == nil { + log.Println("๐ซ No mailer configured, canceling domain expiration checks.") + return + } + + // for every domain in the domains configuration, if alerts are turned on, check the expiration from the WHOIS cache and then send an alert if one hasn't been sent. + for _, domain := range domains.DomainFile.Domains { + if domain.Alerts { + whoisEntry := whoisCache.Get(domain.FQDN) + if whoisEntry == nil { + log.Printf("โ WHOIS entry for %s not found, skipping", domain.FQDN) + continue + } + + // Get the days until expiration + daysUntilExpiration := whoisEntry.WhoisInfo.Domain.ExpirationDateInTime.Sub(time.Now()).Hours() / 24 + + // Check the 2-month, 1-month, 2-week, 1-week, 3-day and within 1 week of expiration thresholds + if daysUntilExpiration <= 60 && !whoisEntry.Sent2MonthAlert && appConfig.Alerts.Send2MonthAlert { + if err := mailer.SendAlert(appConfig.Alerts.Admin, domain.FQDN, configuration.Alert2Months); err != nil { + log.Printf("โ Failed to send 2-month alert for %s: %s", domain.FQDN, err) + continue + } + whoisEntry.MarkAlertSent(configuration.Alert2Months) + } + if daysUntilExpiration <= 30 && !whoisEntry.Sent1MonthAlert && appConfig.Alerts.Send1MonthAlert { + if err := mailer.SendAlert(appConfig.Alerts.Admin, domain.FQDN, configuration.Alert1Month); err != nil { + log.Printf("โ Failed to send 1-month alert for %s: %s", domain.FQDN, err) + continue + } + whoisEntry.MarkAlertSent(configuration.Alert1Month) + } + if daysUntilExpiration <= 14 && !whoisEntry.Sent2WeekAlert && appConfig.Alerts.Send2WeekAlert { + if err := mailer.SendAlert(appConfig.Alerts.Admin, domain.FQDN, configuration.Alert2Weeks); err != nil { + log.Printf("โ Failed to send 2-week alert for %s: %s", domain.FQDN, err) + continue + } + whoisEntry.MarkAlertSent(configuration.Alert2Weeks) + } + if daysUntilExpiration <= 7 && !whoisEntry.Sent1WeekAlert && appConfig.Alerts.Send1WeekAlert { + if err := mailer.SendAlert(appConfig.Alerts.Admin, domain.FQDN, configuration.Alert1Week); err != nil { + log.Printf("โ Failed to send 1-week alert for %s: %s", domain.FQDN, err) + continue + } + whoisEntry.MarkAlertSent(configuration.Alert1Week) + } + if daysUntilExpiration <= 3 && !whoisEntry.Sent3DayAlert && appConfig.Alerts.Send3DayAlert { + if err := mailer.SendAlert(appConfig.Alerts.Admin, domain.FQDN, configuration.Alert3Days); err != nil { + log.Printf("โ Failed to send 3-day alert for %s: %s", domain.FQDN, err) + continue + } + whoisEntry.MarkAlertSent(configuration.Alert3Days) + } + // The daily alerts within one week of expiration need to check the last alert sent date, and confirm that expiration is within 7 days + if daysUntilExpiration <= 7 && daysUntilExpiration > 0 && appConfig.Alerts.SendDailyExpiryAlert { + // Check if the last alert sent was on this day, month and year. If it was, don't send another alert. + if whoisEntry.LastAlertSent.Day() == time.Now().Day() && whoisEntry.LastAlertSent.Month() == time.Now().Month() && whoisEntry.LastAlertSent.Year() == time.Now().Year() { + log.Printf("โ ๏ธ Daily alert for %s was already sent today", domain.FQDN) + continue + } + + if err := mailer.SendAlert(appConfig.Alerts.Admin, domain.FQDN, configuration.AlertDaily); err != nil { + log.Printf("โ Failed to send daily alert for %s: %s", domain.FQDN, err) + continue + } + whoisEntry.MarkAlertSent(configuration.AlertDaily) + } + } +} + +time.AfterFunc(interval, func() { domainExpirationCheckOnSchedule(whoisCache, domains, mailer, appConfig, interval) }) +} + // Refresh the whois cache on a schedule, and flush the cache. This runs every 6 hours. func whoisRefreshOnSchedule(whoisCache configuration.WhoisCacheStorage, domains configuration.DomainConfiguration, interval time.Duration) { log.Println("๐ Refreshing WHOIS cache") whoisCache.RefreshWithDomains(domains) whoisCache.Flush() + time.AfterFunc(interval, func() { whoisRefreshOnSchedule(whoisCache, domains, interval) }) } diff --git a/configuration/whois-cache.configuration.go b/configuration/whois-cache.configuration.go index 5229568..3ea50cb 100644 --- a/configuration/whois-cache.configuration.go +++ b/configuration/whois-cache.configuration.go @@ -74,11 +74,13 @@ func (w *WhoisCacheStorage) Add(fqdn string) { LastUpdated: time.Time{}, } - // Perform the whois query + // Perform the whois query for the new domain newEntry.Refresh() // Add the entry to the list w.FileContents.Entries = append(w.FileContents.Entries, newEntry) + // Flush the cache to disk + w.Flush() } func (w *WhoisCacheStorage) Refresh() { @@ -221,7 +223,7 @@ func (w *WhoisCache) MarkAlertSent(alert Alert) { case AlertDaily: // Check if the alert has already been sent, and log the inconsistency // We have to check if the date stored is today to know if we sent it already - if w.LastAlertSent == time.Now() { + if w.LastAlertSent.Day() == time.Now().Day() && w.LastAlertSent.Month() == time.Now().Month() && w.LastAlertSent.Year() == time.Now().Year() { log.Printf("โ ๏ธ %s was already marked as sent for %s!", alert, w.FQDN) } } diff --git a/handlers/whois.handler.go b/handlers/whois.handler.go index cef9be1..065b7b1 100644 --- a/handlers/whois.handler.go +++ b/handlers/whois.handler.go @@ -3,6 +3,7 @@ package handlers import ( "errors" + "github.com/a-h/templ" "github.com/labstack/echo/v4" "github.com/nwesterhausen/domain-monitor/service" "github.com/nwesterhausen/domain-monitor/views/domains" @@ -25,8 +26,14 @@ func (h *WhoisHandler) GetCard(c echo.Context) error { return errors.New("invalid domain to fetch (FQDN required)") } - whois := h.WhoisService.GetWhois(fqdn) - card := domains.WhoisDetail(whois) + var card templ.Component + whois, err := h.WhoisService.GetWhois(fqdn) + + if err != nil { + card = domains.WhoisError(err) + } else { + card = domains.WhoisDetail(whois) + } return View(c, card) } diff --git a/package.json b/package.json index f085323..a12efb7 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "1.0.0-rc1", + "version": "1.1.0", "repository": { "type": "git", "url": "https://github.com/nwesterhausen/domain-monitor" diff --git a/service/domain.service.go b/service/domain.service.go index b9f33fd..71d6d2a 100644 --- a/service/domain.service.go +++ b/service/domain.service.go @@ -24,7 +24,7 @@ func (s *ServicesDomain) CreateDomain(domain configuration.Domain) (int, error) } } // This should never happen.. but just in case return -1 and an error - return -1, errors.New("Failed to add domain") + return -1, errors.New("failed to add domain") } func (s *ServicesDomain) GetDomain(fqdn string) (configuration.Domain, error) { @@ -33,7 +33,7 @@ func (s *ServicesDomain) GetDomain(fqdn string) (configuration.Domain, error) { return d, nil } } - return configuration.Domain{}, errors.New("Domain not found") + return configuration.Domain{}, errors.New("domain not found") } func (s *ServicesDomain) GetDomains() ([]configuration.Domain, error) { @@ -52,7 +52,7 @@ func (s *ServicesDomain) UpdateDomain(domain configuration.Domain) error { } } // This should never happen.. but just in case return an error - return errors.New("Failed to update domain") + return errors.New("failed to update domain") } func (s *ServicesDomain) DeleteDomain(fqdn string) error { @@ -65,7 +65,7 @@ func (s *ServicesDomain) DeleteDomain(fqdn string) error { // Return nil to indicate success (we can confirm the domain was deleted by checking the list) for _, d := range s.store.DomainFile.Domains { if d.FQDN == fqdn { - return errors.New("Failed to delete domain") + return errors.New("failed to delete domain") } } return nil diff --git a/service/mailer.service.go b/service/mailer.service.go index 5dfc1c3..7827dde 100644 --- a/service/mailer.service.go +++ b/service/mailer.service.go @@ -1,6 +1,7 @@ package service import ( + "fmt" "log" "github.com/nwesterhausen/domain-monitor/configuration" @@ -78,3 +79,29 @@ func (m *MailerService) TestMail(to string) error { return nil } + +func (m *MailerService) SendAlert(to string, fqdn string, alert configuration.Alert) error { + msg := mail.NewMsg() + if err := msg.From(m.from); err != nil { + log.Printf("โ failed to set FROM address: %s", err) + return err + } + if err := msg.To(to); err != nil { + log.Printf("โ failed to set TO address: %s", err) + return err + } + msg.Subject("Domain Expiration Alert: " + fqdn) + + body := fmt.Sprintf("Your domain %s is expiring in %s. Please renew it as soon as possible.", fqdn, alert) + + msg.SetBodyString(mail.TypeTextPlain, body) + + if err := m.client.DialAndSend(msg); err != nil { + log.Printf("โ failed to deliver mail: %s", err) + return err + } + + log.Printf("๐ง E-mail message sent to " + to) + + return nil +} diff --git a/service/whois.service.go b/service/whois.service.go index 4717c10..f7d676b 100644 --- a/service/whois.service.go +++ b/service/whois.service.go @@ -1,6 +1,7 @@ package service import ( + "errors" "log" "github.com/nwesterhausen/domain-monitor/configuration" @@ -14,15 +15,24 @@ func NewWhoisService(store configuration.WhoisCacheStorage) *ServicesWhois { return &ServicesWhois{store: store} } -func (s *ServicesWhois) GetWhois(fqdn string) configuration.WhoisCache { +func (s *ServicesWhois) GetWhois(fqdn string) (configuration.WhoisCache, error) { for _, entry := range s.store.FileContents.Entries { if entry.FQDN == fqdn { - return entry + return entry, nil } } - log.Println("WHOIS entry cache miss for", fqdn) + log.Println("๐ WHOIS entry cache miss for", fqdn) - return configuration.WhoisCache{} + // Since we cache missed, let's try to fetch the WHOIS entry instead + s.store.Add(fqdn) + // Try to get the entry again + for _, entry := range s.store.FileContents.Entries { + if entry.FQDN == fqdn { + return entry, nil + } + } + + return configuration.WhoisCache{}, errors.New("entry missing") } func (s *ServicesWhois) MarkAlertSent(fqdn string, alert configuration.Alert) bool { diff --git a/views/domains/domain-dashboard.templ b/views/domains/domain-dashboard.templ index 5ebe526..3e809fb 100644 --- a/views/domains/domain-dashboard.templ +++ b/views/domains/domain-dashboard.templ @@ -4,6 +4,7 @@ import ( "github.com/nwesterhausen/domain-monitor/configuration" "strings" "time" + "fmt" "github.com/hako/durafmt" ) @@ -32,12 +33,11 @@ templ DomainCards(domains []configuration.Domain) { } -templ WhoisDetail(whois configuration.WhoisCache) { - if (configuration.WhoisCache{}) == whois { -