mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-05 05:42:56 +00:00
Initial commit: Beszel fork with Domain Locker integration
This commit is contained in:
@@ -0,0 +1,428 @@
|
||||
package domains
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/domain"
|
||||
"github.com/henrygd/beszel/internal/hub/domains/whois"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// APIHandler handles domain API requests
|
||||
type APIHandler struct {
|
||||
app core.App
|
||||
scheduler *Scheduler
|
||||
}
|
||||
|
||||
// NewAPIHandler creates a new domain API handler
|
||||
func NewAPIHandler(app core.App, scheduler *Scheduler) *APIHandler {
|
||||
return &APIHandler{
|
||||
app: app,
|
||||
scheduler: scheduler,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers domain API routes
|
||||
func (h *APIHandler) RegisterRoutes(se *core.ServeEvent) {
|
||||
api := se.Router.Group("/api/beszel/domains")
|
||||
api.Bind(apis.RequireAuth())
|
||||
|
||||
api.GET("/", h.listDomains)
|
||||
api.POST("/", h.createDomain)
|
||||
api.POST("/lookup", h.lookupDomain)
|
||||
api.GET("/{id}", h.getDomain)
|
||||
api.PATCH("/{id}", h.updateDomain)
|
||||
api.DELETE("/{id}", h.deleteDomain)
|
||||
api.POST("/{id}/refresh", h.refreshDomain)
|
||||
api.GET("/{id}/history", h.getDomainHistory)
|
||||
}
|
||||
|
||||
// listDomains lists all domains for the authenticated user
|
||||
func (h *APIHandler) listDomains(e *core.RequestEvent) error {
|
||||
authRecord := e.Auth
|
||||
if authRecord == nil {
|
||||
return e.UnauthorizedError("unauthorized", nil)
|
||||
}
|
||||
|
||||
records, err := h.app.FindAllRecords("domains",
|
||||
dbx.NewExp("user = {:user}", dbx.Params{"user": authRecord.Id}),
|
||||
)
|
||||
if err != nil {
|
||||
return e.InternalServerError("failed to fetch domains", err)
|
||||
}
|
||||
|
||||
domains := make([]map[string]interface{}, 0, len(records))
|
||||
for _, record := range records {
|
||||
domains = append(domains, h.recordToResponse(record))
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, domains)
|
||||
}
|
||||
|
||||
// lookupDomain performs a WHOIS lookup without saving
|
||||
func (h *APIHandler) lookupDomain(e *core.RequestEvent) error {
|
||||
authRecord := e.Auth
|
||||
if authRecord == nil {
|
||||
return e.UnauthorizedError("unauthorized", nil)
|
||||
}
|
||||
|
||||
var req struct {
|
||||
DomainName string `json:"domain_name"`
|
||||
}
|
||||
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
|
||||
return e.BadRequestError("invalid request body", err)
|
||||
}
|
||||
|
||||
if req.DomainName == "" {
|
||||
return e.BadRequestError("domain_name is required", nil)
|
||||
}
|
||||
|
||||
// Clean domain
|
||||
domainName := cleanDomain(req.DomainName)
|
||||
|
||||
// Perform lookup
|
||||
lookupSvc := whois.NewLookupService("")
|
||||
ctx := e.Request.Context()
|
||||
domainData, err := lookupSvc.LookupDomain(ctx, domainName)
|
||||
if err != nil {
|
||||
return e.InternalServerError("lookup failed", err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, domainData)
|
||||
}
|
||||
|
||||
// createDomain creates a new domain entry
|
||||
func (h *APIHandler) createDomain(e *core.RequestEvent) error {
|
||||
authRecord := e.Auth
|
||||
if authRecord == nil {
|
||||
return e.UnauthorizedError("unauthorized", nil)
|
||||
}
|
||||
|
||||
var req struct {
|
||||
DomainName string `json:"domain_name"`
|
||||
AutoLookup bool `json:"auto_lookup"`
|
||||
Tags []string `json:"tags"`
|
||||
Notes string `json:"notes"`
|
||||
PurchasePrice float64 `json:"purchase_price"`
|
||||
CurrentValue float64 `json:"current_value"`
|
||||
RenewalCost float64 `json:"renewal_cost"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
AlertDaysBefore int `json:"alert_days_before"`
|
||||
SSLAlertEnabled bool `json:"ssl_alert_enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
|
||||
return e.BadRequestError("invalid request body", err)
|
||||
}
|
||||
|
||||
if req.DomainName == "" {
|
||||
return e.BadRequestError("domain_name is required", nil)
|
||||
}
|
||||
|
||||
// Clean domain
|
||||
domainName := cleanDomain(req.DomainName)
|
||||
|
||||
// Check if domain already exists for this user
|
||||
existing, _ := h.app.FindFirstRecordByFilter("domains",
|
||||
"domain_name = {:domain} && user = {:user}",
|
||||
dbx.Params{"domain": domainName, "user": authRecord.Id})
|
||||
if existing != nil {
|
||||
return e.BadRequestError("domain already exists", nil)
|
||||
}
|
||||
|
||||
collection, err := h.app.FindCollectionByNameOrId("domains")
|
||||
if err != nil {
|
||||
return e.InternalServerError("failed to find collection", err)
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if req.AlertDaysBefore <= 0 {
|
||||
req.AlertDaysBefore = 30
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
record.Set("domain_name", domainName)
|
||||
record.Set("status", domain.DomainStatusUnknown)
|
||||
record.Set("active", true)
|
||||
record.Set("tags", req.Tags)
|
||||
record.Set("notes", req.Notes)
|
||||
record.Set("purchase_price", req.PurchasePrice)
|
||||
record.Set("current_value", req.CurrentValue)
|
||||
record.Set("renewal_cost", req.RenewalCost)
|
||||
record.Set("auto_renew", req.AutoRenew)
|
||||
record.Set("alert_days_before", req.AlertDaysBefore)
|
||||
record.Set("ssl_alert_enabled", req.SSLAlertEnabled)
|
||||
record.Set("user", authRecord.Id)
|
||||
|
||||
// Auto-lookup if requested
|
||||
if req.AutoLookup {
|
||||
lookupSvc := whois.NewLookupService("")
|
||||
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)
|
||||
record.Set("registrar_name", domainData.RegistrarName)
|
||||
record.Set("registrar_id", domainData.RegistrarID)
|
||||
record.Set("registrar_url", domainData.RegistrarURL)
|
||||
record.Set("dnssec", domainData.DNSSEC)
|
||||
record.Set("name_servers", domainData.NameServers)
|
||||
record.Set("mx_records", domainData.MXRecords)
|
||||
record.Set("txt_records", domainData.TXTRecords)
|
||||
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)
|
||||
record.Set("host_country", domainData.HostCountry)
|
||||
record.Set("host_isp", domainData.HostISP)
|
||||
record.Set("favicon_url", domainData.FaviconURL)
|
||||
record.Set("last_checked", time.Now())
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.app.Save(record); err != nil {
|
||||
return e.InternalServerError("failed to create domain", err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusCreated, h.recordToResponse(record))
|
||||
}
|
||||
|
||||
// getDomain gets a single domain
|
||||
func (h *APIHandler) getDomain(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)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, h.recordToResponse(record))
|
||||
}
|
||||
|
||||
// updateDomain updates a domain
|
||||
func (h *APIHandler) updateDomain(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)
|
||||
}
|
||||
|
||||
var req map[string]interface{}
|
||||
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
|
||||
return e.BadRequestError("invalid request body", err)
|
||||
}
|
||||
|
||||
// Update allowed fields
|
||||
if tags, ok := req["tags"]; ok {
|
||||
record.Set("tags", tags)
|
||||
}
|
||||
if notes, ok := req["notes"]; ok {
|
||||
record.Set("notes", notes)
|
||||
}
|
||||
if price, ok := req["purchase_price"]; ok {
|
||||
record.Set("purchase_price", price)
|
||||
}
|
||||
if value, ok := req["current_value"]; ok {
|
||||
record.Set("current_value", value)
|
||||
}
|
||||
if renewal, ok := req["renewal_cost"]; ok {
|
||||
record.Set("renewal_cost", renewal)
|
||||
}
|
||||
if autoRenew, ok := req["auto_renew"]; ok {
|
||||
record.Set("auto_renew", autoRenew)
|
||||
}
|
||||
if active, ok := req["active"]; ok {
|
||||
record.Set("active", active)
|
||||
}
|
||||
if alertDays, ok := req["alert_days_before"]; ok {
|
||||
record.Set("alert_days_before", alertDays)
|
||||
}
|
||||
if sslAlert, ok := req["ssl_alert_enabled"]; ok {
|
||||
record.Set("ssl_alert_enabled", sslAlert)
|
||||
}
|
||||
|
||||
if err := h.app.Save(record); err != nil {
|
||||
return e.InternalServerError("failed to update domain", err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, h.recordToResponse(record))
|
||||
}
|
||||
|
||||
// deleteDomain deletes a domain
|
||||
func (h *APIHandler) deleteDomain(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)
|
||||
}
|
||||
|
||||
if err := h.app.Delete(record); err != nil {
|
||||
return e.InternalServerError("failed to delete domain", err)
|
||||
}
|
||||
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// refreshDomain triggers a manual refresh
|
||||
func (h *APIHandler) refreshDomain(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)
|
||||
}
|
||||
|
||||
// Trigger refresh via scheduler
|
||||
if h.scheduler != nil {
|
||||
h.scheduler.RefreshDomain(id)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]string{"status": "refreshing"})
|
||||
}
|
||||
|
||||
// getDomainHistory gets the change history for a domain
|
||||
func (h *APIHandler) getDomainHistory(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)
|
||||
}
|
||||
|
||||
// Fetch history
|
||||
records, err := h.app.FindAllRecords("domain_history",
|
||||
dbx.NewExp("domain = {:domain}", dbx.Params{"domain": id}),
|
||||
)
|
||||
if err != nil {
|
||||
return e.InternalServerError("failed to fetch history", err)
|
||||
}
|
||||
|
||||
history := make([]map[string]interface{}, 0, len(records))
|
||||
for _, record := range records {
|
||||
history = append(history, map[string]interface{}{
|
||||
"id": record.Id,
|
||||
"change_type": record.GetString("change_type"),
|
||||
"field_name": record.GetString("field_name"),
|
||||
"old_value": record.GetString("old_value"),
|
||||
"new_value": record.GetString("new_value"),
|
||||
"created_at": record.GetDateTime("created_at").String(),
|
||||
})
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, history)
|
||||
}
|
||||
|
||||
// recordToResponse converts a record to API response
|
||||
func (h *APIHandler) recordToResponse(record *core.Record) map[string]interface{} {
|
||||
expiryDate := record.GetDateTime("expiry_date").Time()
|
||||
sslValidTo := record.GetDateTime("ssl_valid_to").Time()
|
||||
|
||||
// Calculate days until expiry
|
||||
daysUntilExpiry := -1
|
||||
if !expiryDate.IsZero() {
|
||||
daysUntilExpiry = int(time.Until(expiryDate).Hours() / 24)
|
||||
}
|
||||
|
||||
sslDaysUntil := -1
|
||||
if !sslValidTo.IsZero() {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
// cleanDomain cleans and normalizes a domain name
|
||||
func cleanDomain(domain string) string {
|
||||
// Remove protocol
|
||||
domain = strings.TrimPrefix(domain, "https://")
|
||||
domain = strings.TrimPrefix(domain, "http://")
|
||||
// Remove www prefix
|
||||
domain = strings.TrimPrefix(domain, "www.")
|
||||
// Remove path and query
|
||||
if idx := strings.IndexAny(domain, "/?#"); idx != -1 {
|
||||
domain = domain[:idx]
|
||||
}
|
||||
// Remove port
|
||||
if idx := strings.Index(domain, ":"); idx != -1 {
|
||||
domain = domain[:idx]
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(domain))
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package domains
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/domain"
|
||||
"github.com/henrygd/beszel/internal/hub/domains/whois"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// NewScheduler creates a new domain scheduler
|
||||
func NewScheduler(app core.App) *Scheduler {
|
||||
return &Scheduler{
|
||||
app: app,
|
||||
whois: whois.NewLookupService(""), // API key can be configured via env
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the domain check scheduler
|
||||
func (s *Scheduler) Start() {
|
||||
log.Println("[domain-scheduler] Starting domain scheduler")
|
||||
|
||||
// Check domains daily
|
||||
s.ticker = time.NewTicker(24 * time.Hour)
|
||||
|
||||
// Run initial check immediately
|
||||
go s.checkDomains()
|
||||
|
||||
// Schedule periodic checks
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-s.ticker.C:
|
||||
s.checkDomains()
|
||||
case <-s.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop halts the domain scheduler
|
||||
func (s *Scheduler) Stop() {
|
||||
log.Println("[domain-scheduler] Stopping domain scheduler")
|
||||
if s.ticker != nil {
|
||||
s.ticker.Stop()
|
||||
}
|
||||
close(s.stopChan)
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
// checkDomains checks all active domains for expiry and updates info
|
||||
func (s *Scheduler) checkDomains() {
|
||||
log.Println("[domain-scheduler] Checking domains")
|
||||
|
||||
// Find all active domains
|
||||
records, err := s.app.FindAllRecords("domains",
|
||||
dbx.NewExp("active = true"),
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("[domain-scheduler] Failed to fetch domains: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
s.wg.Add(1)
|
||||
go func(r *core.Record) {
|
||||
defer s.wg.Done()
|
||||
s.checkDomain(r)
|
||||
}(record)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Perform WHOIS and DNS lookup
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
newData, err := s.whois.LookupDomain(ctx, domainName)
|
||||
if err != nil {
|
||||
log.Printf("[domain-scheduler] Failed to lookup %s: %v", domainName, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
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)
|
||||
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() {
|
||||
status = domain.DomainStatusExpired
|
||||
} else if newData.IsExpiring() {
|
||||
status = domain.DomainStatusExpiring
|
||||
}
|
||||
} else {
|
||||
status = domain.DomainStatusUnknown
|
||||
}
|
||||
record.Set("status", status)
|
||||
|
||||
if err := s.app.Save(record); err != nil {
|
||||
log.Printf("[domain-scheduler] Failed to update %s: %v", domainName, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Save history entries
|
||||
for _, h := range history {
|
||||
s.saveHistory(h, record.Id, userID)
|
||||
}
|
||||
|
||||
// Trigger notifications for expiring domains
|
||||
if status == domain.DomainStatusExpiring || status == domain.DomainStatusExpired {
|
||||
s.triggerNotification(record, status)
|
||||
}
|
||||
|
||||
// Check SSL expiry
|
||||
if newData.SSLAlertEnabled && newData.SSLValidTo != nil {
|
||||
sslDays := newData.SSLDaysUntilExpiry()
|
||||
if sslDays <= newData.AlertDaysBefore {
|
||||
s.triggerSSLNotification(record, sslDays)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[domain-scheduler] Updated domain: %s (status: %s)", domainName, status)
|
||||
}
|
||||
|
||||
// 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
|
||||
now := time.Now()
|
||||
|
||||
// Check expiry date change
|
||||
oldExpiry := oldRecord.GetDateTime("expiry_date").Time()
|
||||
if newData.ExpiryDate != nil && !oldExpiry.IsZero() && !newData.ExpiryDate.Equal(oldExpiry) {
|
||||
history = append(history, domain.DomainHistory{
|
||||
ChangeType: domain.ChangeTypeExpiry,
|
||||
FieldName: "expiry_date",
|
||||
OldValue: oldExpiry.Format("2006-01-02"),
|
||||
NewValue: newData.ExpiryDate.Format("2006-01-02"),
|
||||
CreatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// Check registrar change
|
||||
oldRegistrar := oldRecord.GetString("registrar_name")
|
||||
if newData.RegistrarName != "" && newData.RegistrarName != oldRegistrar {
|
||||
history = append(history, domain.DomainHistory{
|
||||
ChangeType: domain.ChangeTypeRegistrar,
|
||||
FieldName: "registrar_name",
|
||||
OldValue: oldRegistrar,
|
||||
NewValue: newData.RegistrarName,
|
||||
CreatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// Check status change
|
||||
oldStatus := oldRecord.GetString("status")
|
||||
newStatus := newData.GetStatus()
|
||||
if newStatus != oldStatus {
|
||||
history = append(history, domain.DomainHistory{
|
||||
ChangeType: domain.ChangeTypeStatus,
|
||||
FieldName: "status",
|
||||
OldValue: oldStatus,
|
||||
NewValue: newStatus,
|
||||
CreatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// Check SSL expiry change
|
||||
oldSSLExpiry := oldRecord.GetDateTime("ssl_valid_to").Time()
|
||||
if newData.SSLValidTo != nil && !oldSSLExpiry.IsZero() && !newData.SSLValidTo.Equal(oldSSLExpiry) {
|
||||
history = append(history, domain.DomainHistory{
|
||||
ChangeType: domain.ChangeTypeSSL,
|
||||
FieldName: "ssl_valid_to",
|
||||
OldValue: oldSSLExpiry.Format("2006-01-02"),
|
||||
NewValue: newData.SSLValidTo.Format("2006-01-02"),
|
||||
CreatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
return history
|
||||
}
|
||||
|
||||
// saveHistory saves a history entry to the database
|
||||
func (s *Scheduler) saveHistory(h domain.DomainHistory, domainID, userID string) {
|
||||
collection, err := s.app.FindCollectionByNameOrId("domain_history")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
record.Set("domain", domainID)
|
||||
record.Set("change_type", h.ChangeType)
|
||||
record.Set("field_name", h.FieldName)
|
||||
record.Set("old_value", h.OldValue)
|
||||
record.Set("new_value", h.NewValue)
|
||||
record.Set("user", userID)
|
||||
record.Set("created_at", h.CreatedAt)
|
||||
|
||||
if err := s.app.Save(record); err != nil {
|
||||
log.Printf("[domain-scheduler] Failed to save history: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// triggerNotification sends notification for domain events
|
||||
func (s *Scheduler) triggerNotification(record *core.Record, status string) {
|
||||
domainName := record.GetString("domain_name")
|
||||
daysUntil := 0
|
||||
|
||||
if expiry := record.GetDateTime("expiry_date"); !expiry.IsZero() {
|
||||
daysUntil = int(time.Until(expiry.Time()).Hours() / 24)
|
||||
}
|
||||
|
||||
var title, body string
|
||||
switch status {
|
||||
case domain.DomainStatusExpired:
|
||||
title = fmt.Sprintf("Domain Expired: %s", domainName)
|
||||
body = fmt.Sprintf("The domain %s has expired.", domainName)
|
||||
case domain.DomainStatusExpiring:
|
||||
title = fmt.Sprintf("Domain Expiring Soon: %s", domainName)
|
||||
body = fmt.Sprintf("The domain %s expires in %d days.", domainName, daysUntil)
|
||||
}
|
||||
|
||||
log.Printf("[domain-scheduler] %s: %s", title, body)
|
||||
|
||||
// TODO: Integrate with notification system
|
||||
// This would call the notification dispatcher similar to monitor alerts
|
||||
}
|
||||
|
||||
// triggerSSLNotification sends notification for SSL expiry
|
||||
func (s *Scheduler) triggerSSLNotification(record *core.Record, daysUntil int) {
|
||||
domainName := record.GetString("domain_name")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// RefreshDomain manually refreshes a single domain
|
||||
func (s *Scheduler) RefreshDomain(domainID string) error {
|
||||
record, err := s.app.FindRecordById("domains", domainID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
s.checkDomain(record)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckAllDomains manually triggers a check of all active domains
|
||||
func (s *Scheduler) CheckAllDomains() {
|
||||
s.checkDomains()
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
package whois
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/domain"
|
||||
)
|
||||
|
||||
// LookupService handles WHOIS lookups with multiple fallback methods
|
||||
type LookupService struct {
|
||||
whoisXMLAPIKey string
|
||||
rdapCache map[string]string
|
||||
}
|
||||
|
||||
// NewLookupService creates a new WHOIS lookup service
|
||||
func NewLookupService(apiKey string) *LookupService {
|
||||
return &LookupService{
|
||||
whoisXMLAPIKey: apiKey,
|
||||
rdapCache: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// LookupDomain performs a comprehensive domain lookup (WHOIS, DNS, SSL, Host)
|
||||
func (s *LookupService) LookupDomain(ctx context.Context, domainName string) (*domain.Domain, error) {
|
||||
// Clean domain name
|
||||
domainName = cleanDomain(domainName)
|
||||
|
||||
// Initialize domain struct
|
||||
d := &domain.Domain{
|
||||
DomainName: domainName,
|
||||
Active: true,
|
||||
AlertDaysBefore: 30, // Default: alert 30 days before expiry
|
||||
Tags: []string{},
|
||||
NameServers: []string{},
|
||||
MXRecords: []string{},
|
||||
TXTRecords: []string{},
|
||||
IPv4Addresses: []string{},
|
||||
IPv6Addresses: []string{},
|
||||
}
|
||||
|
||||
// Perform WHOIS lookup
|
||||
whoisData, err := s.LookupWHOIS(ctx, domainName)
|
||||
if err == nil && whoisData != nil {
|
||||
s.applyWHOISData(d, whoisData)
|
||||
}
|
||||
|
||||
// Perform DNS lookups
|
||||
s.lookupDNS(ctx, domainName, d)
|
||||
|
||||
// Perform SSL lookup
|
||||
s.lookupSSL(ctx, domainName, d)
|
||||
|
||||
// Perform host lookup (using first IPv4)
|
||||
if len(d.IPv4Addresses) > 0 {
|
||||
s.lookupHost(d.IPv4Addresses[0], d)
|
||||
}
|
||||
|
||||
// Fetch favicon
|
||||
d.FaviconURL = fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", domainName)
|
||||
|
||||
d.LastChecked = time.Now()
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// LookupWHOIS performs WHOIS lookup with multiple fallback methods
|
||||
func (s *LookupService) LookupWHOIS(ctx context.Context, domainName string) (*domain.WHOISData, error) {
|
||||
// Try RDAP first (modern replacement for WHOIS)
|
||||
data, err := s.tryRDAP(ctx, domainName)
|
||||
if err == nil && data != nil && hasValidData(data) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Try native whois command
|
||||
data, err = s.tryNativeWHOIS(ctx, domainName)
|
||||
if err == nil && data != nil && hasValidData(data) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Try WhoisXML API if key is configured
|
||||
if s.whoisXMLAPIKey != "" {
|
||||
data, err = s.tryWhoisXML(ctx, domainName)
|
||||
if err == nil && data != nil {
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("all WHOIS lookup methods failed for %s", domainName)
|
||||
}
|
||||
|
||||
// tryRDAP attempts RDAP lookup
|
||||
func (s *LookupService) tryRDAP(ctx context.Context, domainName string) (*domain.WHOISData, error) {
|
||||
// Get TLD
|
||||
parts := strings.Split(domainName, ".")
|
||||
if len(parts) < 2 {
|
||||
return nil, fmt.Errorf("invalid domain format")
|
||||
}
|
||||
tld := parts[len(parts)-1]
|
||||
|
||||
// Get RDAP base URL
|
||||
baseURL, err := s.getRDAPBaseURL(ctx, tld)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Make RDAP request
|
||||
url := fmt.Sprintf("%s/domain/%s", baseURL, domainName)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/rdap+json")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("RDAP returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var rdapResp struct {
|
||||
LdhName string `json:"ldhName"`
|
||||
Handle string `json:"handle"`
|
||||
Status []string `json:"status"`
|
||||
Events []struct {
|
||||
EventAction string `json:"eventAction"`
|
||||
EventDate string `json:"eventDate"`
|
||||
} `json:"events"`
|
||||
Entities []struct {
|
||||
Roles []string `json:"roles"`
|
||||
PublicIds []struct {
|
||||
Type string `json:"type"`
|
||||
Identifier string `json:"identifier"`
|
||||
} `json:"publicIds"`
|
||||
VCardArray []interface{} `json:"vcardArray"`
|
||||
} `json:"entities"`
|
||||
SecureDNS struct {
|
||||
ZoneSigned bool `json:"zoneSigned"`
|
||||
} `json:"secureDNS"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rdapResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse events
|
||||
var creationDate, expiryDate, updatedDate *time.Time
|
||||
for _, event := range rdapResp.Events {
|
||||
t, _ := time.Parse(time.RFC3339, event.EventDate)
|
||||
switch event.EventAction {
|
||||
case "registration":
|
||||
creationDate = &t
|
||||
case "expiration":
|
||||
expiryDate = &t
|
||||
case "last changed":
|
||||
updatedDate = &t
|
||||
}
|
||||
}
|
||||
|
||||
// Find registrar
|
||||
var registrarName, registrarID string
|
||||
for _, entity := range rdapResp.Entities {
|
||||
for _, role := range entity.Roles {
|
||||
if role == "registrar" {
|
||||
// Try to get name from vCard
|
||||
if len(entity.VCardArray) > 1 {
|
||||
if vcard, ok := entity.VCardArray[1].([]interface{}); ok {
|
||||
for _, item := range vcard {
|
||||
if arr, ok := item.([]interface{}); ok && len(arr) >= 4 {
|
||||
if arr[0] == "fn" {
|
||||
if name, ok := arr[3].(string); ok {
|
||||
registrarName = name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Get IANA ID
|
||||
for _, pid := range entity.PublicIds {
|
||||
if pid.Type == "IANA Registrar ID" {
|
||||
registrarID = pid.Identifier
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dnssec := ""
|
||||
if rdapResp.SecureDNS.ZoneSigned {
|
||||
dnssec = "signed"
|
||||
}
|
||||
|
||||
return &domain.WHOISData{
|
||||
DomainName: rdapResp.LdhName,
|
||||
Status: rdapResp.Status,
|
||||
DNSSEC: dnssec,
|
||||
Dates: domain.WHOISDates{
|
||||
ExpiryDate: expiryDate,
|
||||
CreationDate: creationDate,
|
||||
UpdatedDate: updatedDate,
|
||||
},
|
||||
Registrar: domain.WHOISRegistrar{
|
||||
Name: registrarName,
|
||||
ID: registrarID,
|
||||
URL: "",
|
||||
RegistryDomainID: rdapResp.Handle,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// tryNativeWHOIS tries the native whois command
|
||||
func (s *LookupService) tryNativeWHOIS(ctx context.Context, domainName string) (*domain.WHOISData, error) {
|
||||
// Check if whois command exists
|
||||
_, err := exec.LookPath("whois")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("whois command not found")
|
||||
}
|
||||
|
||||
// Execute whois with timeout
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(cmdCtx, "whois", domainName)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.parseWHOISOutput(string(output), domainName)
|
||||
}
|
||||
|
||||
// tryWhoisXML tries the WhoisXML API
|
||||
func (s *LookupService) tryWhoisXML(ctx context.Context, domainName string) (*domain.WHOISData, error) {
|
||||
if s.whoisXMLAPIKey == "" {
|
||||
return nil, fmt.Errorf("no API key configured")
|
||||
}
|
||||
|
||||
url := fmt.Sprintf(
|
||||
"https://www.whoisxmlapi.com/whoisserver/WhoisService?apiKey=%s&outputFormat=json&domainName=%s",
|
||||
s.whoisXMLAPIKey, domainName,
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("WhoisXML API returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
WhoisRecord struct {
|
||||
DomainName string `json:"domainName"`
|
||||
RegistrarName string `json:"registrarName"`
|
||||
RegistrarIANAID string `json:"registrarIANAID"`
|
||||
RegistryData struct {
|
||||
Status string `json:"status"`
|
||||
CreatedDateNormalized string `json:"createdDateNormalized"`
|
||||
ExpiresDateNormalized string `json:"expiresDateNormalized"`
|
||||
UpdatedDateNormalized string `json:"updatedDateNormalized"`
|
||||
WhoisServer string `json:"whoisServer"`
|
||||
} `json:"registryData"`
|
||||
} `json:"WhoisRecord"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record := result.WhoisRecord
|
||||
registry := record.RegistryData
|
||||
|
||||
creationDate, _ := time.Parse("2006-01-02", registry.CreatedDateNormalized)
|
||||
expiryDate, _ := time.Parse("2006-01-02", registry.ExpiresDateNormalized)
|
||||
updatedDate, _ := time.Parse("2006-01-02", registry.UpdatedDateNormalized)
|
||||
|
||||
return &domain.WHOISData{
|
||||
DomainName: record.DomainName,
|
||||
Status: strings.Split(registry.Status, ", "),
|
||||
Dates: domain.WHOISDates{
|
||||
ExpiryDate: &expiryDate,
|
||||
CreationDate: &creationDate,
|
||||
UpdatedDate: &updatedDate,
|
||||
},
|
||||
Registrar: domain.WHOISRegistrar{
|
||||
Name: record.RegistrarName,
|
||||
ID: record.RegistrarIANAID,
|
||||
URL: fmt.Sprintf("https://%s", registry.WhoisServer),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseWHOISOutput parses the raw WHOIS text output
|
||||
func (s *LookupService) parseWHOISOutput(output, domainName string) (*domain.WHOISData, error) {
|
||||
lines := strings.Split(output, "\n")
|
||||
data := make(map[string]string)
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "%") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse "Key: Value" format
|
||||
if idx := strings.Index(line, ":"); idx > 0 {
|
||||
key := strings.ToLower(strings.TrimSpace(line[:idx]))
|
||||
value := strings.TrimSpace(line[idx+1:])
|
||||
// Normalize key
|
||||
key = strings.ReplaceAll(key, " ", "_")
|
||||
key = strings.ReplaceAll(key, "/", "_")
|
||||
if value != "" && !strings.HasPrefix(value, "REDACTED") {
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract dates
|
||||
expiryDate := s.parseDate(data["registry_expiry_date"], data["registrar_registration_expiration_date"],
|
||||
data["expiry_date"], data["expiration_time"], data["expire"], data["paid_until"])
|
||||
creationDate := s.parseDate(data["creation_date"], data["created_date"], data["registration_time"])
|
||||
updatedDate := s.parseDate(data["updated_date"], data["last_updated"])
|
||||
|
||||
// Extract registrar - try multiple field names used by different WHOIS servers
|
||||
registrarName := data["registrar"]
|
||||
if registrarName == "" {
|
||||
registrarName = data["registrar_name"]
|
||||
}
|
||||
if registrarName == "" {
|
||||
registrarName = data["sponsoring_registrar"]
|
||||
}
|
||||
if registrarName == "" {
|
||||
registrarName = data["registrar_organization"]
|
||||
}
|
||||
if registrarName == "" {
|
||||
registrarName = "Unknown"
|
||||
}
|
||||
|
||||
// Parse status
|
||||
statusStr := data["domain_status"]
|
||||
var statuses []string
|
||||
if statusStr != "" {
|
||||
statuses = s.parseStatus(statusStr)
|
||||
}
|
||||
|
||||
// Extract registrant contact info
|
||||
registrant := domain.WHOISContact{
|
||||
Name: data["registrant_name"],
|
||||
Organization: data["registrant_organization"],
|
||||
Street: data["registrant_street"],
|
||||
City: data["registrant_city"],
|
||||
State: data["registrant_state_province"],
|
||||
Country: data["registrant_country"],
|
||||
PostalCode: data["registrant_postal_code"],
|
||||
}
|
||||
|
||||
// Try alternate field names for registrant
|
||||
if registrant.Name == "" {
|
||||
registrant.Name = data["registrant"]
|
||||
}
|
||||
if registrant.Organization == "" {
|
||||
registrant.Organization = data["org"]
|
||||
}
|
||||
if registrant.Country == "" {
|
||||
registrant.Country = data["country"]
|
||||
}
|
||||
|
||||
// Parse DNSSEC more thoroughly
|
||||
dnssec := data["dnssec"]
|
||||
if dnssec == "" {
|
||||
// Try alternate field names
|
||||
dnssec = data["dnssec_signed"]
|
||||
}
|
||||
if dnssec == "" {
|
||||
dnssec = data["signed_dnssec"]
|
||||
}
|
||||
// Normalize DNSSEC value
|
||||
dnssec = strings.ToLower(strings.TrimSpace(dnssec))
|
||||
if dnssec == "signed" || dnssec == "yes" || dnssec == "true" {
|
||||
dnssec = "signed"
|
||||
} else if dnssec == "unsigned" || dnssec == "no" || dnssec == "false" {
|
||||
dnssec = "unsigned"
|
||||
}
|
||||
|
||||
return &domain.WHOISData{
|
||||
DomainName: domainName,
|
||||
Status: statuses,
|
||||
DNSSEC: dnssec,
|
||||
Dates: domain.WHOISDates{
|
||||
ExpiryDate: expiryDate,
|
||||
CreationDate: creationDate,
|
||||
UpdatedDate: updatedDate,
|
||||
},
|
||||
Registrar: domain.WHOISRegistrar{
|
||||
Name: registrarName,
|
||||
ID: data["registrar_iana_id"],
|
||||
URL: data["registrar_url"],
|
||||
RegistryDomainID: data["registry_domain_id"],
|
||||
},
|
||||
Registrant: registrant,
|
||||
Abuse: domain.WHOISAbuse{
|
||||
Email: data["registrar_abuse_contact_email"],
|
||||
Phone: data["registrar_abuse_contact_phone"],
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseDate attempts to parse a date from multiple possible formats
|
||||
func (s *LookupService) parseDate(dates ...string) *time.Time {
|
||||
formats := []string{
|
||||
// Standard ISO formats
|
||||
"2006-01-02",
|
||||
"2006-01-02T15:04:05Z",
|
||||
"2006-01-02T15:04:05-07:00",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02 15:04:05.0",
|
||||
// US formats
|
||||
"01/02/2006",
|
||||
"01/02/2006 15:04:05",
|
||||
// European formats
|
||||
"02/01/2006",
|
||||
"02.01.2006",
|
||||
// Verbal formats
|
||||
"Jan 2 2006",
|
||||
"January 2 2006",
|
||||
"2 Jan 2006",
|
||||
"2 January 2006",
|
||||
"Jan 02 2006",
|
||||
"02-Jan-2006",
|
||||
"2-Jan-2006",
|
||||
// With timezone names
|
||||
"2006-01-02 15:04:05 MST",
|
||||
"2006-01-02 15:04:05 UTC",
|
||||
// Common registrar formats
|
||||
"Monday, January 2, 2006",
|
||||
"Mon, 02 Jan 2006 15:04:05 MST",
|
||||
"Mon, 2 Jan 2006 15:04:05 MST",
|
||||
// Additional formats
|
||||
"20060102",
|
||||
"20060102150405",
|
||||
}
|
||||
|
||||
for _, dateStr := range dates {
|
||||
if dateStr == "" || dateStr == "REDACTED" || strings.Contains(dateStr, "REDACTED") {
|
||||
continue
|
||||
}
|
||||
dateStr = strings.TrimSpace(dateStr)
|
||||
// Remove common prefixes/suffixes that don't help
|
||||
dateStr = strings.TrimPrefix(dateStr, "before ")
|
||||
dateStr = strings.TrimPrefix(dateStr, "after ")
|
||||
|
||||
for _, format := range formats {
|
||||
if t, err := time.Parse(format, dateStr); err == nil {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseStatus parses domain status from WHOIS output
|
||||
func (s *LookupService) parseStatus(statusStr string) []string {
|
||||
knownStatuses := []string{
|
||||
"clientDeleteProhibited", "clientHold", "clientRenewProhibited",
|
||||
"clientTransferProhibited", "clientUpdateProhibited",
|
||||
"serverDeleteProhibited", "serverHold", "serverRenewProhibited",
|
||||
"serverTransferProhibited", "serverUpdateProhibited",
|
||||
"inactive", "ok", "pendingCreate", "pendingDelete", "pendingRenew",
|
||||
"pendingRestore", "pendingTransfer", "pendingUpdate",
|
||||
"addPeriod", "autoRenewPeriod", "renewPeriod", "transferPeriod",
|
||||
}
|
||||
|
||||
statusStr = strings.ToLower(statusStr)
|
||||
var matches []string
|
||||
for _, status := range knownStatuses {
|
||||
if strings.Contains(statusStr, strings.ToLower(status)) {
|
||||
matches = append(matches, status)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
// getRDAPBaseURL gets the RDAP base URL for a TLD
|
||||
func (s *LookupService) getRDAPBaseURL(ctx context.Context, tld string) (string, error) {
|
||||
// Check cache
|
||||
if url, ok := s.rdapCache[tld]; ok {
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// Fetch IANA RDAP bootstrap
|
||||
url := "https://data.iana.org/rdap/dns.json"
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var bootstrap struct {
|
||||
Services [][]interface{} `json:"services"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&bootstrap); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Populate cache and find URL for this TLD
|
||||
for _, service := range bootstrap.Services {
|
||||
if len(service) >= 2 {
|
||||
tlds, ok1 := service[0].([]interface{})
|
||||
urls, ok2 := service[1].([]interface{})
|
||||
if ok1 && ok2 && len(urls) > 0 {
|
||||
if urlStr, ok := urls[0].(string); ok {
|
||||
for _, t := range tlds {
|
||||
if tldStr, ok := t.(string); ok {
|
||||
s.rdapCache[tldStr] = strings.TrimSuffix(urlStr, "/")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if url, ok := s.rdapCache[tld]; ok {
|
||||
return url, nil
|
||||
}
|
||||
return "", fmt.Errorf("no RDAP server found for TLD .%s", tld)
|
||||
}
|
||||
|
||||
// lookupDNS performs DNS lookups
|
||||
func (s *LookupService) lookupDNS(ctx context.Context, domainName string, d *domain.Domain) {
|
||||
// NS records
|
||||
nsRecords, _ := net.LookupNS(domainName)
|
||||
for _, ns := range nsRecords {
|
||||
d.NameServers = append(d.NameServers, ns.Host)
|
||||
}
|
||||
|
||||
// MX records
|
||||
mxRecords, _ := net.LookupMX(domainName)
|
||||
for _, mx := range mxRecords {
|
||||
d.MXRecords = append(d.MXRecords, fmt.Sprintf("%s (priority: %d)", mx.Host, mx.Pref))
|
||||
}
|
||||
|
||||
// TXT records
|
||||
txtRecords, _ := net.LookupTXT(domainName)
|
||||
d.TXTRecords = txtRecords
|
||||
|
||||
// IPv4
|
||||
ipv4Addrs, _ := net.LookupHost(domainName)
|
||||
for _, ip := range ipv4Addrs {
|
||||
if strings.Contains(ip, ".") {
|
||||
d.IPv4Addresses = append(d.IPv4Addresses, ip)
|
||||
}
|
||||
}
|
||||
|
||||
// IPv6
|
||||
ipv6Addrs, _ := net.LookupIP(domainName)
|
||||
for _, ip := range ipv6Addrs {
|
||||
if ip.To4() == nil {
|
||||
d.IPv6Addresses = append(d.IPv6Addresses, ip.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lookupSSL fetches SSL certificate info
|
||||
func (s *LookupService) lookupSSL(ctx context.Context, domainName string, d *domain.Domain) {
|
||||
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", domainName+":443", &tls.Config{
|
||||
ServerName: domainName,
|
||||
InsecureSkipVerify: true,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
cert := conn.ConnectionState().PeerCertificates[0]
|
||||
if cert != nil {
|
||||
if len(cert.Issuer.Organization) > 0 {
|
||||
d.SSLIssuer = cert.Issuer.Organization[0]
|
||||
}
|
||||
if len(cert.Issuer.Country) > 0 {
|
||||
d.SSLIssuerCountry = cert.Issuer.Country[0]
|
||||
}
|
||||
d.SSLValidFrom = &cert.NotBefore
|
||||
d.SSLValidTo = &cert.NotAfter
|
||||
d.SSLSubject = cert.Subject.CommonName
|
||||
|
||||
// Format fingerprint as colon-separated hex
|
||||
if len(cert.Signature) > 0 {
|
||||
fingerprint := fmt.Sprintf("%X", cert.Signature)
|
||||
// Add colons every 2 characters for standard format
|
||||
if len(fingerprint) > 2 {
|
||||
var formatted []string
|
||||
for i := 0; i < len(fingerprint); i += 2 {
|
||||
if i+2 <= len(fingerprint) {
|
||||
formatted = append(formatted, fingerprint[i:i+2])
|
||||
}
|
||||
}
|
||||
d.SSLFingerprint = strings.Join(formatted, ":")
|
||||
} else {
|
||||
d.SSLFingerprint = fingerprint
|
||||
}
|
||||
}
|
||||
|
||||
// Extract signature algorithm
|
||||
d.SSLSignatureAlgo = cert.SignatureAlgorithm.String()
|
||||
|
||||
// Safely extract key size for different key types
|
||||
switch key := cert.PublicKey.(type) {
|
||||
case *rsa.PublicKey:
|
||||
d.SSLKeySize = key.N.BitLen()
|
||||
default:
|
||||
// For ECC keys, try to determine from curve
|
||||
d.SSLKeySize = 256 // Default for ECC
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lookupHost fetches host/geolocation info
|
||||
func (s *LookupService) lookupHost(ip string, d *domain.Domain) {
|
||||
// Use ip-api.com (free, no auth required for non-commercial use)
|
||||
url := fmt.Sprintf("http://ip-api.com/json/%s?fields=status,message,country,regionName,city,lat,lon,isp,org,as", ip)
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Country string `json:"country"`
|
||||
Region string `json:"regionName"`
|
||||
City string `json:"city"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
ISP string `json:"isp"`
|
||||
Org string `json:"org"`
|
||||
AS string `json:"as"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if result.Status == "success" {
|
||||
d.HostCountry = result.Country
|
||||
d.HostRegion = result.Region
|
||||
d.HostCity = result.City
|
||||
d.HostLat = result.Lat
|
||||
d.HostLon = result.Lon
|
||||
d.HostISP = result.ISP
|
||||
d.HostOrg = result.Org
|
||||
d.HostAS = result.AS
|
||||
}
|
||||
}
|
||||
|
||||
// applyWHOISData applies WHOIS data to domain struct
|
||||
func (s *LookupService) applyWHOISData(d *domain.Domain, whois *domain.WHOISData) {
|
||||
d.DomainName = whois.DomainName
|
||||
d.Status = strings.Join(whois.Status, ", ")
|
||||
d.DNSSEC = whois.DNSSEC
|
||||
d.ExpiryDate = whois.Dates.ExpiryDate
|
||||
d.CreationDate = whois.Dates.CreationDate
|
||||
d.UpdatedDate = whois.Dates.UpdatedDate
|
||||
d.RegistrarName = whois.Registrar.Name
|
||||
d.RegistrarID = whois.Registrar.ID
|
||||
d.RegistrarURL = whois.Registrar.URL
|
||||
d.RegistryDomainID = whois.Registrar.RegistryDomainID
|
||||
|
||||
// Apply registrant contact info if available
|
||||
if whois.Registrant.Name != "" || whois.Registrant.Organization != "" {
|
||||
d.RegistrantName = whois.Registrant.Name
|
||||
d.RegistrantOrg = whois.Registrant.Organization
|
||||
d.RegistrantStreet = whois.Registrant.Street
|
||||
d.RegistrantCity = whois.Registrant.City
|
||||
d.RegistrantState = whois.Registrant.State
|
||||
d.RegistrantCountry = whois.Registrant.Country
|
||||
d.RegistrantPostal = whois.Registrant.PostalCode
|
||||
}
|
||||
|
||||
// Apply abuse contact info
|
||||
if whois.Abuse.Email != "" || whois.Abuse.Phone != "" {
|
||||
d.AbuseEmail = whois.Abuse.Email
|
||||
d.AbusePhone = whois.Abuse.Phone
|
||||
}
|
||||
}
|
||||
|
||||
// cleanDomain cleans and normalizes a domain name
|
||||
func cleanDomain(domain string) string {
|
||||
// Remove protocol
|
||||
domain = regexp.MustCompile(`^https?://`).ReplaceAllString(domain, "")
|
||||
// Remove www prefix
|
||||
domain = regexp.MustCompile(`^www\.`).ReplaceAllString(domain, "")
|
||||
// Remove path and query
|
||||
if idx := strings.IndexAny(domain, "/?#"); idx != -1 {
|
||||
domain = domain[:idx]
|
||||
}
|
||||
// Remove port
|
||||
if idx := strings.Index(domain, ":"); idx != -1 {
|
||||
domain = domain[:idx]
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(domain))
|
||||
}
|
||||
|
||||
// hasValidData checks if WHOIS data has the minimum required fields
|
||||
func hasValidData(data *domain.WHOISData) bool {
|
||||
return data != nil && (data.Dates.ExpiryDate != nil || data.Registrar.Name != "")
|
||||
}
|
||||
Reference in New Issue
Block a user