mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-03 21:02:56 +00:00
Add public monitoring features and CI updates
- Add status pages, incidents, badges, maintenance, bulk ops, and metrics - Add Docker packaging, env example, and frontend routes - Refresh GitHub workflows and project metadata
This commit is contained in:
+212
-34
@@ -40,6 +40,9 @@ func (h *APIHandler) RegisterRoutes(se *core.ServeEvent) {
|
||||
api.DELETE("/{id}", h.deleteDomain)
|
||||
api.POST("/{id}/refresh", h.refreshDomain)
|
||||
api.GET("/{id}/history", h.getDomainHistory)
|
||||
api.GET("/{id}/stats", h.getDomainStats)
|
||||
api.POST("/{id}/pause", h.pauseDomain)
|
||||
api.POST("/{id}/resume", h.resumeDomain)
|
||||
}
|
||||
|
||||
// listDomains lists all domains for the authenticated user
|
||||
@@ -164,9 +167,21 @@ func (h *APIHandler) createDomain(e *core.RequestEvent) error {
|
||||
ctx := e.Request.Context()
|
||||
domainData, err := lookupSvc.LookupDomain(ctx, domainName)
|
||||
if err == nil && domainData != nil {
|
||||
record.Set("expiry_date", domainData.ExpiryDate)
|
||||
record.Set("creation_date", domainData.CreationDate)
|
||||
record.Set("updated_date", domainData.UpdatedDate)
|
||||
if domainData.ExpiryDate != nil {
|
||||
record.Set("expiry_date", *domainData.ExpiryDate)
|
||||
} else {
|
||||
record.Set("expiry_date", "")
|
||||
}
|
||||
if domainData.CreationDate != nil {
|
||||
record.Set("creation_date", *domainData.CreationDate)
|
||||
} else {
|
||||
record.Set("creation_date", "")
|
||||
}
|
||||
if domainData.UpdatedDate != nil {
|
||||
record.Set("updated_date", *domainData.UpdatedDate)
|
||||
} else {
|
||||
record.Set("updated_date", "")
|
||||
}
|
||||
record.Set("registrar_name", domainData.RegistrarName)
|
||||
record.Set("registrar_id", domainData.RegistrarID)
|
||||
record.Set("registrar_url", domainData.RegistrarURL)
|
||||
@@ -177,7 +192,11 @@ func (h *APIHandler) createDomain(e *core.RequestEvent) error {
|
||||
record.Set("ipv4_addresses", domainData.IPv4Addresses)
|
||||
record.Set("ipv6_addresses", domainData.IPv6Addresses)
|
||||
record.Set("ssl_issuer", domainData.SSLIssuer)
|
||||
record.Set("ssl_valid_to", domainData.SSLValidTo)
|
||||
if domainData.SSLValidTo != nil {
|
||||
record.Set("ssl_valid_to", *domainData.SSLValidTo)
|
||||
} else {
|
||||
record.Set("ssl_valid_to", "")
|
||||
}
|
||||
record.Set("host_country", domainData.HostCountry)
|
||||
record.Set("host_isp", domainData.HostISP)
|
||||
record.Set("favicon_url", domainData.FaviconURL)
|
||||
@@ -360,6 +379,140 @@ func (h *APIHandler) getDomainHistory(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusOK, history)
|
||||
}
|
||||
|
||||
// getDomainStats gets domain health statistics
|
||||
func (h *APIHandler) getDomainStats(e *core.RequestEvent) error {
|
||||
authRecord := e.Auth
|
||||
if authRecord == nil {
|
||||
return e.UnauthorizedError("unauthorized", nil)
|
||||
}
|
||||
|
||||
id := e.Request.PathValue("id")
|
||||
|
||||
// Verify domain ownership
|
||||
domain, err := h.app.FindRecordById("domains", id)
|
||||
if err != nil {
|
||||
return e.NotFoundError("domain not found", err)
|
||||
}
|
||||
if domain.GetString("user") != authRecord.Id {
|
||||
return e.ForbiddenError("not authorized", nil)
|
||||
}
|
||||
|
||||
// Calculate stats from domain history
|
||||
stats := h.calculateDomainStats(id)
|
||||
|
||||
return e.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// calculateDomainStats calculates health statistics from domain history
|
||||
func (h *APIHandler) calculateDomainStats(domainID string) map[string]interface{} {
|
||||
// Get history for the last 30 days
|
||||
since := time.Now().AddDate(0, 0, -30)
|
||||
records, _ := h.app.FindRecordsByFilter(
|
||||
"domain_history",
|
||||
"domain = {:domain} && created_at >= {:since}",
|
||||
"-created_at",
|
||||
0, 0,
|
||||
dbx.Params{
|
||||
"domain": domainID,
|
||||
"since": since.Format("2006-01-02 15:04:05"),
|
||||
},
|
||||
)
|
||||
|
||||
totalChanges := len(records)
|
||||
expiryChanges := 0
|
||||
sslChanges := 0
|
||||
statusChanges := 0
|
||||
|
||||
for _, record := range records {
|
||||
switch record.GetString("change_type") {
|
||||
case "expiry":
|
||||
expiryChanges++
|
||||
case "ssl":
|
||||
sslChanges++
|
||||
case "status":
|
||||
statusChanges++
|
||||
}
|
||||
}
|
||||
|
||||
// Get incidents count
|
||||
incidentRecords, _ := h.app.FindRecordsByFilter(
|
||||
"incidents",
|
||||
"domain = {:domain}",
|
||||
"-created",
|
||||
0, 0,
|
||||
dbx.Params{"domain": domainID},
|
||||
)
|
||||
|
||||
return map[string]interface{}{
|
||||
"total_changes": totalChanges,
|
||||
"expiry_changes": expiryChanges,
|
||||
"ssl_changes": sslChanges,
|
||||
"status_changes": statusChanges,
|
||||
"incidents_count": len(incidentRecords),
|
||||
"period_days": 30,
|
||||
}
|
||||
}
|
||||
|
||||
// pauseDomain pauses domain monitoring
|
||||
func (h *APIHandler) pauseDomain(e *core.RequestEvent) error {
|
||||
authRecord := e.Auth
|
||||
if authRecord == nil {
|
||||
return e.UnauthorizedError("unauthorized", nil)
|
||||
}
|
||||
|
||||
id := e.Request.PathValue("id")
|
||||
record, err := h.app.FindRecordById("domains", id)
|
||||
if err != nil {
|
||||
return e.NotFoundError("domain not found", err)
|
||||
}
|
||||
|
||||
if record.GetString("user") != authRecord.Id {
|
||||
return e.ForbiddenError("not authorized", nil)
|
||||
}
|
||||
|
||||
record.Set("active", false)
|
||||
record.Set("status", "paused")
|
||||
|
||||
if err := h.app.Save(record); err != nil {
|
||||
return e.InternalServerError("failed to pause domain", err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, h.recordToResponse(record))
|
||||
}
|
||||
|
||||
// resumeDomain resumes domain monitoring
|
||||
func (h *APIHandler) resumeDomain(e *core.RequestEvent) error {
|
||||
authRecord := e.Auth
|
||||
if authRecord == nil {
|
||||
return e.UnauthorizedError("unauthorized", nil)
|
||||
}
|
||||
|
||||
id := e.Request.PathValue("id")
|
||||
record, err := h.app.FindRecordById("domains", id)
|
||||
if err != nil {
|
||||
return e.NotFoundError("domain not found", err)
|
||||
}
|
||||
|
||||
if record.GetString("user") != authRecord.Id {
|
||||
return e.ForbiddenError("not authorized", nil)
|
||||
}
|
||||
|
||||
record.Set("active", true)
|
||||
// Reset status - scheduler will update on next check
|
||||
record.Set("status", "unknown")
|
||||
|
||||
if err := h.app.Save(record); err != nil {
|
||||
return e.InternalServerError("failed to resume domain", err)
|
||||
}
|
||||
|
||||
// Trigger immediate refresh
|
||||
if h.scheduler != nil {
|
||||
h.scheduler.RefreshDomain(id)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, h.recordToResponse(record))
|
||||
}
|
||||
|
||||
// recordToResponse converts a record to API response
|
||||
func (h *APIHandler) recordToResponse(record *core.Record) map[string]interface{} {
|
||||
expiryDate := record.GetDateTime("expiry_date").Time()
|
||||
@@ -376,37 +529,62 @@ func (h *APIHandler) recordToResponse(record *core.Record) map[string]interface{
|
||||
sslDaysUntil = int(time.Until(sslValidTo).Hours() / 24)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"id": record.Id,
|
||||
"domain_name": record.GetString("domain_name"),
|
||||
"status": record.GetString("status"),
|
||||
"active": record.GetBool("active"),
|
||||
"expiry_date": expiryDate,
|
||||
"creation_date": record.GetDateTime("creation_date").String(),
|
||||
"updated_date": record.GetDateTime("updated_date").String(),
|
||||
"days_until_expiry": daysUntilExpiry,
|
||||
"registrar_name": record.GetString("registrar_name"),
|
||||
"registrar_id": record.GetString("registrar_id"),
|
||||
"name_servers": record.Get("name_servers"),
|
||||
"ipv4_addresses": record.Get("ipv4_addresses"),
|
||||
"ssl_issuer": record.GetString("ssl_issuer"),
|
||||
"ssl_valid_to": sslValidTo,
|
||||
"ssl_days_until": sslDaysUntil,
|
||||
"host_country": record.GetString("host_country"),
|
||||
"host_isp": record.GetString("host_isp"),
|
||||
"purchase_price": record.GetFloat("purchase_price"),
|
||||
"current_value": record.GetFloat("current_value"),
|
||||
"renewal_cost": record.GetFloat("renewal_cost"),
|
||||
"auto_renew": record.GetBool("auto_renew"),
|
||||
"alert_days_before": record.GetInt("alert_days_before"),
|
||||
"ssl_alert_enabled": record.GetBool("ssl_alert_enabled"),
|
||||
"tags": record.Get("tags"),
|
||||
"notes": record.GetString("notes"),
|
||||
"favicon_url": record.GetString("favicon_url"),
|
||||
"last_checked": record.GetDateTime("last_checked").String(),
|
||||
"created": record.GetDateTime("created").String(),
|
||||
"updated": record.GetDateTime("updated").String(),
|
||||
resp := map[string]interface{}{
|
||||
"id": record.Id,
|
||||
"domain_name": record.GetString("domain_name"),
|
||||
"status": record.GetString("status"),
|
||||
"active": record.GetBool("active"),
|
||||
"days_until_expiry": daysUntilExpiry,
|
||||
"registrar_name": record.GetString("registrar_name"),
|
||||
"registrar_id": record.GetString("registrar_id"),
|
||||
"name_servers": record.Get("name_servers"),
|
||||
"ipv4_addresses": record.Get("ipv4_addresses"),
|
||||
"ssl_issuer": record.GetString("ssl_issuer"),
|
||||
"ssl_issuer_country": record.GetString("ssl_issuer_country"),
|
||||
"ssl_subject": record.GetString("ssl_subject"),
|
||||
"ssl_days_until": sslDaysUntil,
|
||||
"ssl_fingerprint": record.GetString("ssl_fingerprint"),
|
||||
"ssl_key_size": record.GetInt("ssl_key_size"),
|
||||
"ssl_signature_algo": record.GetString("ssl_signature_algo"),
|
||||
"host_country": record.GetString("host_country"),
|
||||
"host_isp": record.GetString("host_isp"),
|
||||
"purchase_price": record.GetFloat("purchase_price"),
|
||||
"current_value": record.GetFloat("current_value"),
|
||||
"renewal_cost": record.GetFloat("renewal_cost"),
|
||||
"auto_renew": record.GetBool("auto_renew"),
|
||||
"alert_days_before": record.GetInt("alert_days_before"),
|
||||
"ssl_alert_enabled": record.GetBool("ssl_alert_enabled"),
|
||||
"tags": record.Get("tags"),
|
||||
"notes": record.GetString("notes"),
|
||||
"favicon_url": record.GetString("favicon_url"),
|
||||
"created": record.GetDateTime("created").String(),
|
||||
"updated": record.GetDateTime("updated").String(),
|
||||
}
|
||||
|
||||
if !expiryDate.IsZero() {
|
||||
resp["expiry_date"] = expiryDate.Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
creationDate := record.GetDateTime("creation_date").Time()
|
||||
if !creationDate.IsZero() {
|
||||
resp["creation_date"] = creationDate.Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
updatedDate := record.GetDateTime("updated_date").Time()
|
||||
if !updatedDate.IsZero() {
|
||||
resp["updated_date"] = updatedDate.Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
sslValidFrom := record.GetDateTime("ssl_valid_from").Time()
|
||||
if !sslValidFrom.IsZero() {
|
||||
resp["ssl_valid_from"] = sslValidFrom.Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
if !sslValidTo.IsZero() {
|
||||
resp["ssl_valid_to"] = sslValidTo.Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
lastChecked := record.GetDateTime("last_checked").Time()
|
||||
if !lastChecked.IsZero() {
|
||||
resp["last_checked"] = lastChecked.Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// cleanDomain cleans and normalizes a domain name
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -13,13 +15,17 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// AlertCallback is a function that sends alerts
|
||||
type AlertCallback func(userID, title, message, link, linkText string)
|
||||
|
||||
// Scheduler manages periodic domain checks for expiry and SSL
|
||||
type Scheduler struct {
|
||||
app core.App
|
||||
whois *whois.LookupService
|
||||
ticker *time.Ticker
|
||||
stopChan chan struct{}
|
||||
wg sync.WaitGroup
|
||||
app core.App
|
||||
whois *whois.LookupService
|
||||
ticker *time.Ticker
|
||||
stopChan chan struct{}
|
||||
wg sync.WaitGroup
|
||||
alertCallback AlertCallback
|
||||
}
|
||||
|
||||
// NewScheduler creates a new domain scheduler
|
||||
@@ -31,6 +37,11 @@ func NewScheduler(app core.App) *Scheduler {
|
||||
}
|
||||
}
|
||||
|
||||
// SetAlertCallback sets the callback function for sending alerts
|
||||
func (s *Scheduler) SetAlertCallback(callback AlertCallback) {
|
||||
s.alertCallback = callback
|
||||
}
|
||||
|
||||
// Start begins the domain check scheduler
|
||||
func (s *Scheduler) Start() {
|
||||
log.Println("[domain-scheduler] Starting domain scheduler")
|
||||
@@ -89,9 +100,10 @@ func (s *Scheduler) checkDomains() {
|
||||
// checkDomain checks a single domain
|
||||
func (s *Scheduler) checkDomain(record *core.Record) {
|
||||
domainName := record.GetString("domain_name")
|
||||
|
||||
userID := record.GetString("user")
|
||||
|
||||
log.Printf("[domain-scheduler] Checking domain: %s", domainName)
|
||||
log.Printf("[domain-scheduler] Checking domain: %s for user %s", domainName, userID)
|
||||
|
||||
// Perform WHOIS and DNS lookup
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
@@ -106,32 +118,94 @@ func (s *Scheduler) checkDomain(record *core.Record) {
|
||||
// Track changes
|
||||
history := s.trackChanges(record, newData)
|
||||
|
||||
// Update record
|
||||
record.Set("expiry_date", newData.ExpiryDate)
|
||||
record.Set("creation_date", newData.CreationDate)
|
||||
record.Set("updated_date", newData.UpdatedDate)
|
||||
record.Set("registrar_name", newData.RegistrarName)
|
||||
record.Set("registrar_id", newData.RegistrarID)
|
||||
record.Set("registrar_url", newData.RegistrarURL)
|
||||
// Update record (only overwrite if new data is present to preserve valid data on partial lookups)
|
||||
if newData.ExpiryDate != nil {
|
||||
record.Set("expiry_date", *newData.ExpiryDate)
|
||||
}
|
||||
if newData.CreationDate != nil {
|
||||
record.Set("creation_date", *newData.CreationDate)
|
||||
}
|
||||
if newData.UpdatedDate != nil {
|
||||
record.Set("updated_date", *newData.UpdatedDate)
|
||||
}
|
||||
if newData.RegistrarName != "" {
|
||||
record.Set("registrar_name", newData.RegistrarName)
|
||||
}
|
||||
if newData.RegistrarID != "" {
|
||||
record.Set("registrar_id", newData.RegistrarID)
|
||||
}
|
||||
if newData.RegistrarURL != "" {
|
||||
record.Set("registrar_url", newData.RegistrarURL)
|
||||
}
|
||||
record.Set("dnssec", newData.DNSSEC)
|
||||
record.Set("name_servers", newData.NameServers)
|
||||
record.Set("mx_records", newData.MXRecords)
|
||||
record.Set("txt_records", newData.TXTRecords)
|
||||
record.Set("ipv4_addresses", newData.IPv4Addresses)
|
||||
record.Set("ipv6_addresses", newData.IPv6Addresses)
|
||||
record.Set("ssl_issuer", newData.SSLIssuer)
|
||||
record.Set("ssl_valid_to", newData.SSLValidTo)
|
||||
if len(newData.NameServers) > 0 {
|
||||
record.Set("name_servers", newData.NameServers)
|
||||
}
|
||||
if len(newData.MXRecords) > 0 {
|
||||
record.Set("mx_records", newData.MXRecords)
|
||||
}
|
||||
if len(newData.TXTRecords) > 0 {
|
||||
record.Set("txt_records", newData.TXTRecords)
|
||||
}
|
||||
if len(newData.IPv4Addresses) > 0 {
|
||||
record.Set("ipv4_addresses", newData.IPv4Addresses)
|
||||
}
|
||||
if len(newData.IPv6Addresses) > 0 {
|
||||
record.Set("ipv6_addresses", newData.IPv6Addresses)
|
||||
}
|
||||
|
||||
// Update SSL info - only overwrite if new data is present to avoid losing valid SSL data on lookup failure
|
||||
if newData.SSLIssuer != "" {
|
||||
record.Set("ssl_issuer", newData.SSLIssuer)
|
||||
}
|
||||
if newData.SSLIssuerCountry != "" {
|
||||
record.Set("ssl_issuer_country", newData.SSLIssuerCountry)
|
||||
}
|
||||
if newData.SSLSubject != "" {
|
||||
record.Set("ssl_subject", newData.SSLSubject)
|
||||
}
|
||||
if newData.SSLValidFrom != nil && !newData.SSLValidFrom.IsZero() {
|
||||
record.Set("ssl_valid_from", *newData.SSLValidFrom)
|
||||
}
|
||||
if newData.SSLValidTo != nil && !newData.SSLValidTo.IsZero() {
|
||||
record.Set("ssl_valid_to", *newData.SSLValidTo)
|
||||
}
|
||||
if newData.SSLFingerprint != "" {
|
||||
record.Set("ssl_fingerprint", newData.SSLFingerprint)
|
||||
}
|
||||
if newData.SSLKeySize > 0 {
|
||||
record.Set("ssl_key_size", newData.SSLKeySize)
|
||||
}
|
||||
if newData.SSLSignatureAlgo != "" {
|
||||
record.Set("ssl_signature_algo", newData.SSLSignatureAlgo)
|
||||
}
|
||||
record.Set("host_country", newData.HostCountry)
|
||||
record.Set("host_isp", newData.HostISP)
|
||||
record.Set("last_checked", time.Now())
|
||||
|
||||
// Update status
|
||||
status := domain.DomainStatusActive
|
||||
if newData.ExpiryDate != nil {
|
||||
if newData.IsExpired() {
|
||||
// Update status - fallback to existing record expiry if new lookup didn't return one
|
||||
status := record.GetString("status")
|
||||
if status == "" {
|
||||
status = domain.DomainStatusActive
|
||||
}
|
||||
|
||||
expiryDate := newData.ExpiryDate
|
||||
if expiryDate == nil {
|
||||
existingExpiry := record.GetDateTime("expiry_date")
|
||||
if !existingExpiry.IsZero() {
|
||||
t := existingExpiry.Time()
|
||||
expiryDate = &t
|
||||
}
|
||||
}
|
||||
|
||||
if expiryDate != nil {
|
||||
daysUntil := int(time.Until(*expiryDate).Hours() / 24)
|
||||
if daysUntil < 0 {
|
||||
status = domain.DomainStatusExpired
|
||||
} else if newData.IsExpiring() {
|
||||
} else if daysUntil <= 30 {
|
||||
status = domain.DomainStatusExpiring
|
||||
} else {
|
||||
status = domain.DomainStatusActive
|
||||
}
|
||||
} else {
|
||||
status = domain.DomainStatusUnknown
|
||||
@@ -161,9 +235,67 @@ func (s *Scheduler) checkDomain(record *core.Record) {
|
||||
}
|
||||
}
|
||||
|
||||
// Discover and save subdomains
|
||||
s.discoverSubdomains(record, domainName, userID)
|
||||
|
||||
log.Printf("[domain-scheduler] Updated domain: %s (status: %s)", domainName, status)
|
||||
}
|
||||
|
||||
// discoverSubdomains discovers and saves subdomains for a domain
|
||||
func (s *Scheduler) discoverSubdomains(record *core.Record, domainName, userID string) {
|
||||
// Common subdomains to check
|
||||
commonSubdomains := []string{
|
||||
"www", "mail", "ftp", "api", "blog", "shop", "admin", "app", "cdn",
|
||||
"static", "dev", "staging", "test", "demo", "docs", "support", "help",
|
||||
"status", "monitor", "grafana", "prometheus", "db", "cache", "redis",
|
||||
"queue", "worker", "backup", "media", "assets", "download", "upload",
|
||||
"git", "gitlab", "github", "jenkins", "ci", "cd", "vpn", "ssh",
|
||||
"smtp", "imap", "mx", "webmail", "email", "analytics", "stats",
|
||||
"search", "login", "auth", "sso", "oauth", "account", "user",
|
||||
}
|
||||
|
||||
// Get existing subdomains to avoid duplicates
|
||||
existing, _ := s.app.FindAllRecords("subdomains",
|
||||
dbx.NewExp("domain = {:domain}", dbx.Params{"domain": record.Id}),
|
||||
)
|
||||
existingMap := make(map[string]bool)
|
||||
for _, sub := range existing {
|
||||
existingMap[sub.GetString("subdomain_name")] = true
|
||||
}
|
||||
|
||||
collection, err := s.app.FindCollectionByNameOrId("subdomains")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, sub := range commonSubdomains {
|
||||
if existingMap[sub] {
|
||||
continue
|
||||
}
|
||||
|
||||
fullDomain := sub + "." + domainName
|
||||
ips, err := net.LookupHost(fullDomain)
|
||||
if err != nil || len(ips) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Found a valid subdomain
|
||||
subRecord := core.NewRecord(collection)
|
||||
subRecord.Set("domain", record.Id)
|
||||
subRecord.Set("subdomain_name", sub)
|
||||
subRecord.Set("status", "active")
|
||||
subRecord.Set("ip_addresses", strings.Join(ips, ","))
|
||||
subRecord.Set("last_checked", time.Now())
|
||||
subRecord.Set("user", userID)
|
||||
|
||||
if err := s.app.Save(subRecord); err != nil {
|
||||
log.Printf("[domain-scheduler] Failed to save subdomain %s: %v", fullDomain, err)
|
||||
} else {
|
||||
log.Printf("[domain-scheduler] Discovered subdomain: %s", fullDomain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// trackChanges compares old and new data and returns history entries
|
||||
func (s *Scheduler) trackChanges(oldRecord *core.Record, newData *domain.Domain) []domain.DomainHistory {
|
||||
var history []domain.DomainHistory
|
||||
@@ -245,6 +377,7 @@ func (s *Scheduler) saveHistory(h domain.DomainHistory, domainID, userID string)
|
||||
// triggerNotification sends notification for domain events
|
||||
func (s *Scheduler) triggerNotification(record *core.Record, status string) {
|
||||
domainName := record.GetString("domain_name")
|
||||
userID := record.GetString("user")
|
||||
daysUntil := 0
|
||||
|
||||
if expiry := record.GetDateTime("expiry_date"); !expiry.IsZero() {
|
||||
@@ -263,20 +396,30 @@ func (s *Scheduler) triggerNotification(record *core.Record, status string) {
|
||||
|
||||
log.Printf("[domain-scheduler] %s: %s", title, body)
|
||||
|
||||
// TODO: Integrate with notification system
|
||||
// This would call the notification dispatcher similar to monitor alerts
|
||||
// Send notification via alert callback if available
|
||||
if s.alertCallback != nil && userID != "" {
|
||||
link := fmt.Sprintf("/domain/%s", record.Id)
|
||||
linkText := "View Domain"
|
||||
s.alertCallback(userID, title, body, link, linkText)
|
||||
}
|
||||
}
|
||||
|
||||
// triggerSSLNotification sends notification for SSL expiry
|
||||
func (s *Scheduler) triggerSSLNotification(record *core.Record, daysUntil int) {
|
||||
domainName := record.GetString("domain_name")
|
||||
userID := record.GetString("user")
|
||||
|
||||
title := fmt.Sprintf("SSL Certificate Expiring: %s", domainName)
|
||||
body := fmt.Sprintf("The SSL certificate for %s expires in %d days.", domainName, daysUntil)
|
||||
|
||||
log.Printf("[domain-scheduler] %s: %s", title, body)
|
||||
|
||||
// TODO: Integrate with notification system
|
||||
// Send notification via alert callback if available
|
||||
if s.alertCallback != nil && userID != "" {
|
||||
link := fmt.Sprintf("/domain/%s", record.Id)
|
||||
linkText := "View Domain"
|
||||
s.alertCallback(userID, title, body, link, linkText)
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshDomain manually refreshes a single domain
|
||||
|
||||
@@ -74,17 +74,32 @@ func (s *LookupService) LookupDomain(ctx context.Context, domainName string) (*d
|
||||
|
||||
// LookupWHOIS performs WHOIS lookup with multiple fallback methods
|
||||
func (s *LookupService) LookupWHOIS(ctx context.Context, domainName string) (*domain.WHOISData, error) {
|
||||
var lastErr error
|
||||
|
||||
// Try RDAP first (modern replacement for WHOIS)
|
||||
data, err := s.tryRDAP(ctx, domainName)
|
||||
if err == nil && data != nil && hasValidData(data) {
|
||||
return data, nil
|
||||
}
|
||||
lastErr = err
|
||||
|
||||
// Try pure-Go TCP WHOIS (works in containers without whois binary)
|
||||
data, err = s.tryTCPWHOIS(ctx, domainName)
|
||||
if err == nil && data != nil && hasValidData(data) {
|
||||
return data, nil
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
// Try native whois command
|
||||
data, err = s.tryNativeWHOIS(ctx, domainName)
|
||||
if err == nil && data != nil && hasValidData(data) {
|
||||
return data, nil
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
// Try WhoisXML API if key is configured
|
||||
if s.whoisXMLAPIKey != "" {
|
||||
@@ -94,7 +109,7 @@ func (s *LookupService) LookupWHOIS(ctx context.Context, domainName string) (*do
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("all WHOIS lookup methods failed for %s", domainName)
|
||||
return nil, fmt.Errorf("all WHOIS lookup methods failed for %s: %w", domainName, lastErr)
|
||||
}
|
||||
|
||||
// tryRDAP attempts RDAP lookup
|
||||
@@ -243,6 +258,87 @@ func (s *LookupService) tryNativeWHOIS(ctx context.Context, domainName string) (
|
||||
return s.parseWHOISOutput(string(output), domainName)
|
||||
}
|
||||
|
||||
// whoisServers maps common TLDs to their WHOIS servers
|
||||
var whoisServers = map[string]string{
|
||||
"com": "whois.verisign-grs.com",
|
||||
"net": "whois.verisign-grs.com",
|
||||
"org": "whois.pir.org",
|
||||
"io": "whois.nic.io",
|
||||
"co": "whois.nic.co",
|
||||
"dev": "whois.nic.google",
|
||||
"app": "whois.nic.google",
|
||||
"xyz": "whois.nic.xyz",
|
||||
"info": "whois.afilias.net",
|
||||
"biz": "whois.biz",
|
||||
"us": "whois.nic.us",
|
||||
"uk": "whois.nic.uk",
|
||||
"de": "whois.denic.de",
|
||||
"fr": "whois.nic.fr",
|
||||
"eu": "whois.eu",
|
||||
"nl": "whois.domain-registry.nl",
|
||||
"ca": "whois.cira.ca",
|
||||
"au": "whois.auda.org.au",
|
||||
"me": "whois.nic.me",
|
||||
"tv": "whois.nic.tv",
|
||||
"cc": "whois.nic.cc",
|
||||
"ws": "whois.website.ws",
|
||||
"name": "whois.nic.name",
|
||||
"mobi": "whois.dotmobiregistry.net",
|
||||
"asia": "whois.nic.asia",
|
||||
"pro": "whois.nic.pro",
|
||||
"jobs": "whois.nic.jobs",
|
||||
"travel": "whois.nic.travel",
|
||||
}
|
||||
|
||||
// tryTCPWHOIS performs WHOIS lookup via direct TCP connection (port 43)
|
||||
func (s *LookupService) tryTCPWHOIS(ctx context.Context, domainName string) (*domain.WHOISData, error) {
|
||||
parts := strings.Split(domainName, ".")
|
||||
if len(parts) < 2 {
|
||||
return nil, fmt.Errorf("invalid domain format")
|
||||
}
|
||||
tld := strings.ToLower(parts[len(parts)-1])
|
||||
|
||||
server, ok := whoisServers[tld]
|
||||
if !ok {
|
||||
// Fallback to IANA for unknown TLDs
|
||||
server = "whois.iana.org"
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(server, "43")
|
||||
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tcp whois dial failed: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Some servers require the domain followed by \r\n
|
||||
query := domainName + "\r\n"
|
||||
if _, err := conn.Write([]byte(query)); err != nil {
|
||||
return nil, fmt.Errorf("tcp whois write failed: %w", err)
|
||||
}
|
||||
|
||||
// Read response with deadline
|
||||
if err := conn.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := conn.Read(buf)
|
||||
if n > 0 {
|
||||
output.Write(buf[:n])
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return s.parseWHOISOutput(output.String(), domainName)
|
||||
}
|
||||
|
||||
// tryWhoisXML tries the WhoisXML API
|
||||
func (s *LookupService) tryWhoisXML(ctx context.Context, domainName string) (*domain.WHOISData, error) {
|
||||
if s.whoisXMLAPIKey == "" {
|
||||
@@ -730,7 +826,20 @@ func cleanDomain(domain string) string {
|
||||
return strings.ToLower(strings.TrimSpace(domain))
|
||||
}
|
||||
|
||||
// hasValidData checks if WHOIS data has the minimum required fields
|
||||
// hasValidData checks if WHOIS data has useful parsed fields
|
||||
func hasValidData(data *domain.WHOISData) bool {
|
||||
return data != nil && (data.Dates.ExpiryDate != nil || data.Registrar.Name != "")
|
||||
if data == nil {
|
||||
return false
|
||||
}
|
||||
// Accept if we got any meaningful data
|
||||
if data.Dates.ExpiryDate != nil || data.Dates.CreationDate != nil {
|
||||
return true
|
||||
}
|
||||
if data.Registrar.Name != "" && data.Registrar.Name != "Unknown" {
|
||||
return true
|
||||
}
|
||||
if len(data.Status) > 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user