diff --git a/api/internal/cron/jobs/domain.go b/api/internal/cron/jobs/domain.go index c589ceed..c7a54404 100644 --- a/api/internal/cron/jobs/domain.go +++ b/api/internal/cron/jobs/domain.go @@ -3,6 +3,7 @@ package jobs import ( "context" "log" + "strings" "time" "gorm.io/gorm" @@ -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 { diff --git a/api/internal/model/domain.go b/api/internal/model/domain.go index 3990434c..72f1dd46 100644 --- a/api/internal/model/domain.go +++ b/api/internal/model/domain.go @@ -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"` +} diff --git a/api/internal/service/domain.go b/api/internal/service/domain.go index 4fc14929..373c8844 100644 --- a/api/internal/service/domain.go +++ b/api/internal/service/domain.go @@ -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 { @@ -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) @@ -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() @@ -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 { @@ -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 } diff --git a/api/internal/transport/api/domain.go b/api/internal/transport/api/domain.go index 9754c261..8352b644 100644 --- a/api/internal/transport/api/domain.go +++ b/api/internal/transport/api/domain.go @@ -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 @@ -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, }) } diff --git a/api/internal/utils/dns.go b/api/internal/utils/dns.go index 6468d997..9fd8608e 100644 --- a/api/internal/utils/dns.go +++ b/api/internal/utils/dns.go @@ -17,6 +17,20 @@ 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). // @@ -24,13 +38,9 @@ func stripDot(s string) string { // // 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) @@ -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) @@ -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. diff --git a/api/internal/utils/dns_test.go b/api/internal/utils/dns_test.go index e4eeafe1..0d959f34 100644 --- a/api/internal/utils/dns_test.go +++ b/api/internal/utils/dns_test.go @@ -129,6 +129,99 @@ func TestLookupMX_TrailingDot(t *testing.T) { } } +// validSPFRecord tests + +func TestValidSPFRecord(t *testing.T) { + tests := []struct { + name string + record string + requiredMechanism string + want bool + }{ + {"exact match", "v=spf1 include:spf.mailx.net -all", "spf.mailx.net", true}, + {"softfail all", "v=spf1 include:spf.mailx.net ~all", "spf.mailx.net", true}, + {"extra includes and reordered", "v=spf1 include:_spf.google.com include:spf.mailx.net ~all", "spf.mailx.net", true}, + {"mx mechanism instead of include", "v=spf1 mx -all", "spf.mailx.net", true}, + {"case-insensitive version and mechanism", "V=SPF1 INCLUDE:SPF.MAILX.NET -ALL", "spf.mailx.net", true}, + {"missing required mechanism", "v=spf1 include:spf.other.net -all", "spf.mailx.net", false}, + {"missing all mechanism", "v=spf1 include:spf.mailx.net", "spf.mailx.net", false}, + {"neutral all not accepted", "v=spf1 include:spf.mailx.net ?all", "spf.mailx.net", false}, + {"not an spf record", "some other txt record", "spf.mailx.net", false}, + {"wrong spf version", "v=spf2 include:spf.mailx.net -all", "spf.mailx.net", false}, + {"empty record", "", "spf.mailx.net", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := validSPFRecord(tc.record, tc.requiredMechanism) + if got != tc.want { + t.Errorf("validSPFRecord(%q, %q) = %v, want %v", tc.record, tc.requiredMechanism, got, tc.want) + } + }) + } +} + +// validDMARCRecord tests + +func TestValidDMARCRecord(t *testing.T) { + tests := []struct { + name string + record string + want bool + }{ + {"quarantine", "v=DMARC1; p=quarantine; adkim=s", true}, + {"reject", "v=DMARC1; p=reject", true}, + {"reordered tags", "adkim=s; p=reject; v=DMARC1", true}, + {"case-insensitive", "V=DMARC1; P=Reject", true}, + {"none policy rejected", "v=DMARC1; p=none", false}, + {"missing version tag", "p=reject", false}, + {"missing policy tag", "v=DMARC1; adkim=s", false}, + {"empty record", "", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := validDMARCRecord(tc.record) + if got != tc.want { + t.Errorf("validDMARCRecord(%q) = %v, want %v", tc.record, got, tc.want) + } + }) + } +} + +// LookupSPF tests + +func TestLookupSPF_NotFound(t *testing.T) { + found, err := LookupSPF("nonexistent.invalid", "spf.example.net") + if err != nil { + t.Fatalf("expected nil error for non-existent domain, got: %v", err) + } + if found { + t.Fatal("expected false for non-existent domain") + } +} + +func TestLookupSPF_Mismatch(t *testing.T) { + // gmail.com publishes an SPF record but never includes this mechanism. + found, err := LookupSPF("gmail.com", "spf.this-will-never-exist.example") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found { + t.Fatal("expected false for mismatched SPF mechanism") + } +} + +// LookupDMARC tests + +func TestLookupDMARC_NotFound(t *testing.T) { + found, err := LookupDMARC("_dmarc.nonexistent.invalid") + if err != nil { + t.Fatalf("expected nil error for non-existent domain, got: %v", err) + } + if found { + t.Fatal("expected false for non-existent domain") + } +} + // LookupCNAME tests func TestLookupCNAME_NotFound(t *testing.T) { diff --git a/app/src/components/DomainCreate.vue b/app/src/components/DomainCreate.vue index 95e0e840..21395f68 100644 --- a/app/src/components/DomainCreate.vue +++ b/app/src/components/DomainCreate.vue @@ -255,6 +255,16 @@
+