Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion api/internal/cron/jobs/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package jobs
import (
"context"
"log"
"strings"
"time"

"gorm.io/gorm"
Expand Down Expand Up @@ -69,7 +70,15 @@ func VerifyDomainsJob(cfg config.Config, db *gorm.DB) {
}

// Send records check (SPF, DKIM, DMARC)
if err := svc.VerifyDomainSend(ctx, domain.Name, domain.UserID); err != nil {
if checks, err := svc.VerifyDomainSend(ctx, domain.Name, domain.UserID); err != nil {
var failedChecks []string
for _, c := range checks {
if !c.Passed {
failedChecks = append(failedChecks, c.Name)
}
}
log.Printf("VerifyDomainsJob: send verification failed for domain %s: %s", domain.Name, strings.Join(failedChecks, ", "))

if dbErr := db.Model(&model.Domain{}).Where("id = ?", domain.ID).Updates(map[string]any{
"send_verified_at": nil,
}).Error; dbErr != nil {
Expand Down
6 changes: 6 additions & 0 deletions api/internal/model/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,9 @@ type DNSConfig struct {
DKIM []string `json:"dkim_selectors"`
Hosts []string `json:"mx_hosts"`
}

type RecordCheck struct {
Name string `json:"name"`
Passed bool `json:"passed"`
Error string `json:"error,omitempty"`
}
77 changes: 52 additions & 25 deletions api/internal/service/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ var (
ErrDNSLookupDKIM = errors.New("Unable to verify domain DNS records. Please ensure the correct DKIM records are set or try again later.")
ErrDNSLookupDMARC = errors.New("Unable to verify domain DNS records. Please ensure the correct DMARC record is set or try again later.")
ErrDNSLookupMX = errors.New("Unable to verify domain DNS records. Please ensure the correct MX records are set or try again later.")
ErrDNSLookupSend = errors.New("Unable to verify domain DNS records. Please review the individual record results.")
)

type DomainStore interface {
Expand Down Expand Up @@ -298,11 +299,11 @@ func (s *Service) VerifyOwnerExistingDomain(ctx context.Context, domainId string
return nil
}

func (s *Service) VerifyDomainDNSRecords(ctx context.Context, domainId string, userID string) error {
func (s *Service) VerifyDomainDNSRecords(ctx context.Context, domainId string, userID string) ([]model.RecordCheck, error) {
domain, err := s.GetDomain(ctx, domainId, userID)
if err != nil {
log.Printf("error getting domain for DNS record verification: %s", err.Error())
return ErrGetDomain
return nil, ErrGetDomain
}

// verify, err := s.GetOwnerVerifyRecordExistingDomain(ctx, domainId, userID)
Expand All @@ -328,22 +329,26 @@ func (s *Service) VerifyDomainDNSRecords(ctx context.Context, domainId string, u
// return ErrDNSLookupOwner
// }

mxCheck := model.RecordCheck{Name: "mx", Passed: true}
err = s.VerifyDomainMX(ctx, domain.Name, userID)
if err != nil {
mxCheck.Passed = false
mxCheck.Error = err.Error()
domain.MXVerifiedAt = nil
if updateErr := s.UpdateDomain(ctx, domain); updateErr != nil {
log.Printf("error nulling mx_verified_at for domain %s: %s", domain.Name, updateErr.Error())
}
return err
return []model.RecordCheck{mxCheck}, err
}

err = s.VerifyDomainSend(ctx, domain.Name, userID)
checks, err := s.VerifyDomainSend(ctx, domain.Name, userID)
checks = append([]model.RecordCheck{mxCheck}, checks...)
if err != nil {
domain.SendVerifiedAt = nil
if updateErr := s.UpdateDomain(ctx, domain); updateErr != nil {
log.Printf("error nulling send_verified_at for domain %s: %s", domain.Name, updateErr.Error())
}
return err
return checks, err
}

now := time.Now()
Expand All @@ -354,10 +359,10 @@ func (s *Service) VerifyDomainDNSRecords(ctx context.Context, domainId string, u
err = s.UpdateDomain(ctx, domain)
if err != nil {
log.Printf("error updating domain verification timestamps: %s", err.Error())
return ErrUpdateDomain
return checks, ErrUpdateDomain
}

return nil
return checks, nil
}

func (s *Service) VerifyDomainMX(ctx context.Context, domain string, userID string) error {
Expand All @@ -383,49 +388,71 @@ func (s *Service) VerifyDomainMX(ctx context.Context, domain string, userID stri
return nil
}

func (s *Service) VerifyDomainSend(ctx context.Context, domain string, userID string) error {
func (s *Service) VerifyDomainSend(ctx context.Context, domain string, userID string) ([]model.RecordCheck, error) {
dnsConfig, err := s.GetDNSConfig(ctx, userID)
if err != nil {
log.Printf("error getting DNS config for domain MX verification: %s", err.Error())
return ErrGetDNSConfig
return nil, ErrGetDNSConfig
}

var checks []model.RecordCheck
failed := false

// SPF record
ok, err := utils.LookupTXTContains(domain, "v=spf1 include:spf."+dnsConfig.Domain+" -all")
spfCheck := model.RecordCheck{Name: "spf"}
ok, err := utils.LookupSPF(domain, "spf."+dnsConfig.Domain)
if err != nil {
log.Printf("error looking up TXT record for domain SPF verification: %s", err.Error())
return ErrDNSLookupSPF
spfCheck.Error = ErrDNSLookupSPF.Error()
} else if !ok {
spfCheck.Error = ErrDNSLookupSPF.Error()
} else {
spfCheck.Passed = true
}

if !ok {
return ErrDNSLookupSPF
if !spfCheck.Passed {
failed = true
}
checks = append(checks, spfCheck)

// DKIM records
for _, selector := range dnsConfig.DKIM {
dkimCheck := model.RecordCheck{Name: "dkim:" + selector}
ok, err := utils.LookupCNAME(selector+"._domainkey."+domain, selector+"._domainkey."+dnsConfig.Domain)
if err != nil {
log.Printf("error looking up CNAME record for selector %s in domain DKIM verification: %s", selector, err.Error())
return ErrDNSLookupDKIM
}

if !ok {
dkimCheck.Error = ErrDNSLookupDKIM.Error()
} else if !ok {
log.Printf("DKIM record not found for selector %s in domain DKIM verification", selector)
return ErrDNSLookupDKIM
dkimCheck.Error = ErrDNSLookupDKIM.Error()
} else {
dkimCheck.Passed = true
}
if !dkimCheck.Passed {
failed = true
}
checks = append(checks, dkimCheck)
}

// DMARC record
ok, err = utils.LookupTXTContains("_dmarc."+domain, "v=DMARC1; p=quarantine; adkim=s")
dmarcCheck := model.RecordCheck{Name: "dmarc"}
ok, err = utils.LookupDMARC("_dmarc." + domain)
if err != nil {
log.Printf("error looking up TXT record for domain DMARC verification: %s", err.Error())
return ErrDNSLookupDMARC
dmarcCheck.Error = ErrDNSLookupDMARC.Error()
} else if !ok {
log.Printf("DMARC record not found for domain DMARC verification")
dmarcCheck.Error = ErrDNSLookupDMARC.Error()
} else {
dmarcCheck.Passed = true
}
if !dmarcCheck.Passed {
failed = true
}
checks = append(checks, dmarcCheck)

if !ok {
log.Printf("DMARC record not found for domain DMARC verification")
return ErrDNSLookupDMARC
if failed {
return checks, ErrDNSLookupSend
}

return nil
return checks, nil
}
12 changes: 7 additions & 5 deletions api/internal/transport/api/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ type DomainService interface {
PostDomain(context.Context, model.Domain) (model.Domain, error)
UpdateDomain(context.Context, model.Domain) error
DeleteDomain(context.Context, string, string) error
VerifyDomainDNSRecords(context.Context, string, string) error
VerifyDomainDNSRecords(context.Context, string, string) ([]model.RecordCheck, error)
}

// @Summary Get custom domains
Expand Down Expand Up @@ -217,22 +217,24 @@ func (h *Handler) DeleteDomain(c *fiber.Ctx) error {
// @Produce json
// @Security ApiKeyAuth
// @Param id path string true "Domain ID"
// @Success 200 {object} map[string]string "message"
// @Failure 400 {object} ErrorRes
// @Success 200 {object} map[string]interface{} "message, checks"
// @Failure 400 {object} map[string]interface{} "error, checks"
// @Router /domains/{id}/verify-dns [post]
func (h *Handler) VerifyDomainDNSRecords(c *fiber.Ctx) error {
userID := auth.GetUserID(c)
domainID := c.Params("id")

err := h.Service.VerifyDomainDNSRecords(c.Context(), domainID, userID)
checks, err := h.Service.VerifyDomainDNSRecords(c.Context(), domainID, userID)
if err != nil {
return c.Status(400).JSON(fiber.Map{
"error": err.Error(),
"error": err.Error(),
"checks": checks,
})
}

return c.JSON(fiber.Map{
"message": DNSRecordVerificationSuccess,
"checks": checks,
})
}

Expand Down
117 changes: 105 additions & 12 deletions api/internal/utils/dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,30 @@ func stripDot(s string) string {
return strings.TrimSuffix(s, ".")
}

// lookupTXTRecords looks up TXT records for host. Not-found/permanent DNS
// errors are treated as a plain empty result rather than an error.
func lookupTXTRecords(host string) ([]string, error) {
records, err := net.LookupTXT(host)
if err != nil {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) && (dnsErr.IsNotFound || (!dnsErr.IsTimeout && !dnsErr.IsTemporary)) {
return nil, nil
}
return nil, ErrLookupTXT
}
return records, nil
}

// LookupTXTExact looks up TXT records for host and returns true if any record
// is an exact match to value (trailing dots stripped before comparison).
//
// Example use: verify ownership TXT record
//
// LookupTXTExact("example.com", "service-verify=9487e243822f333d782eabe1115302643b222ef55072c8e77abf75335950a61a")
func LookupTXTExact(host, value string) (bool, error) {
records, err := net.LookupTXT(host)
records, err := lookupTXTRecords(host)
if err != nil {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) && (dnsErr.IsNotFound || (!dnsErr.IsTimeout && !dnsErr.IsTemporary)) {
return false, nil
}
return false, ErrLookupTXT
return false, err
}

want := stripDot(value)
Expand All @@ -50,13 +60,9 @@ func LookupTXTExact(host, value string) (bool, error) {
// LookupTXTContains("example.com", "v=spf1 include:spf.example.net -all")
// LookupTXTContains("_dmarc.example.com", "v=DMARC1; p=quarantine; adkim=s")
func LookupTXTContains(host, value string) (bool, error) {
records, err := net.LookupTXT(host)
records, err := lookupTXTRecords(host)
if err != nil {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) && (dnsErr.IsNotFound || (!dnsErr.IsTimeout && !dnsErr.IsTemporary)) {
return false, nil
}
return false, ErrLookupTXT
return false, err
}

want := stripDot(value)
Expand All @@ -68,6 +74,93 @@ func LookupTXTContains(host, value string) (bool, error) {
return false, nil
}

// validSPFRecord reports whether record is a v=spf1 record that authorizes
// requiredMechanism (an "include:" target, e.g. "spf.example.net", or the bare
// "mx" mechanism) and terminates in a "-all"/"~all" mechanism. Mechanism order
// and any additional mechanisms present are ignored.
func validSPFRecord(record, requiredMechanism string) bool {
fields := strings.Fields(record)
if len(fields) == 0 || !strings.EqualFold(fields[0], "v=spf1") {
return false
}

wantInclude := "include:" + strings.ToLower(requiredMechanism)
hasMechanism := false
hasAll := false
for _, f := range fields[1:] {
switch strings.ToLower(f) {
case wantInclude, "mx":
hasMechanism = true
case "-all", "~all":
hasAll = true
}
}

return hasMechanism && hasAll
}

// validDMARCRecord reports whether record is a v=DMARC1 record with a
// p=quarantine or p=reject policy tag, regardless of tag order.
func validDMARCRecord(record string) bool {
values := make(map[string]string)
for tag := range strings.SplitSeq(record, ";") {
tag = strings.TrimSpace(tag)
key, value, ok := strings.Cut(tag, "=")
if !ok {
continue
}
values[strings.ToLower(strings.TrimSpace(key))] = strings.ToLower(strings.TrimSpace(value))
}

if values["v"] != "dmarc1" {
return false
}
return values["p"] == "quarantine" || values["p"] == "reject"
}

// LookupSPF looks up the SPF TXT record for host and returns true if it
// authorizes requiredMechanism (an "include:" target or the bare "mx"
// mechanism) and ends in a "-all"/"~all" mechanism, regardless of mechanism
// order or additional mechanisms present.
//
// Example use:
//
// LookupSPF("example.com", "spf.example.net")
func LookupSPF(host, requiredMechanism string) (bool, error) {
records, err := lookupTXTRecords(host)
if err != nil {
return false, err
}

for _, r := range records {
if validSPFRecord(stripDot(r), requiredMechanism) {
return true, nil
}
}
return false, nil
}

// LookupDMARC looks up the DMARC TXT record for host (typically
// "_dmarc."+domain) and returns true if it has a p=quarantine or p=reject
// policy, regardless of tag order.
//
// Example use:
//
// LookupDMARC("_dmarc.example.com")
func LookupDMARC(host string) (bool, error) {
records, err := lookupTXTRecords(host)
if err != nil {
return false, err
}

for _, r := range records {
if validDMARCRecord(stripDot(r)) {
return true, nil
}
}
return false, nil
}

// LookupMX looks up MX records for host and returns true if any MX entry's
// hostname matches target (trailing dots stripped, case-insensitive).
// The MX priority/preference value is not checked.
Expand Down
Loading
Loading