Initial commit: Beszel fork with Domain Locker integration

This commit is contained in:
Tomas Dvorak
2026-04-21 15:39:43 +02:00
commit 363d708e91
440 changed files with 160889 additions and 0 deletions
+428
View File
@@ -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))
}
+301
View File
@@ -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()
}
+736
View File
@@ -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 != "")
}